From 2c9944d14eb00f0883c30eb88348a5864d89fddc Mon Sep 17 00:00:00 2001 From: arys Date: Fri, 20 Mar 2026 13:31:44 +0500 Subject: [PATCH] fix --- pom.xml | 14 + .../MarketingAnalysisV3Controller.java | 53 ++ .../parser/dto/MarketingAnalysisV3Result.java | 40 +- .../parser/dto/StrategyHistoryResponse.java | 17 +- .../parser/model/MarketingStrategy.java | 10 + .../service/GeminiVideoGenerationService.java | 210 +++--- .../service/MarketingAnalysisService.java | 363 +++++----- .../service/MarketingAnalysisV3Service.java | 495 ++++++++++---- .../service/MarketingStrategyV3Service.java | 636 ++++++++---------- .../NanoBananaImageGenerationService.java | 2 +- 10 files changed, 1050 insertions(+), 790 deletions(-) diff --git a/pom.xml b/pom.xml index 85726b4..20c6950 100644 --- a/pom.xml +++ b/pom.xml @@ -171,6 +171,20 @@ + + org.apache.maven.plugins + maven-compiler-plugin + 3.14.0 + + + + org.projectlombok + lombok + 1.18.36 + + + + org.springframework.boot spring-boot-maven-plugin diff --git a/src/main/java/kz/konturai/parser/controller/MarketingAnalysisV3Controller.java b/src/main/java/kz/konturai/parser/controller/MarketingAnalysisV3Controller.java index 588dbcc..e663edf 100644 --- a/src/main/java/kz/konturai/parser/controller/MarketingAnalysisV3Controller.java +++ b/src/main/java/kz/konturai/parser/controller/MarketingAnalysisV3Controller.java @@ -574,6 +574,59 @@ public class MarketingAnalysisV3Controller { response.setUserId(strategy.getUserId()); response.setDurationWeeks(strategy.getDurationWeeks()); response.setPriorityPlatforms(strategy.getPriorityPlatforms()); + + // ── Название стратегической модели ─────────────────────────────────── + 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(); + } + if (modelTitle == null && strategy.getStrategyData() != null) { + Object v = strategy.getStrategyData().get("scoringModelTitle"); + if (v != null) modelTitle = v.toString(); + } + response.setScoringModelName(modelName); + response.setScoringModelTitle(modelTitle); + + // ── Название анализа для отображения в списках ─────────────────────── + // Формируем из данных связанного анализа: "Ниша — Продукт/Бренд" + if (strategy.getAnalysisId() != null) { + analysisService.getAnalysisById(strategy.getAnalysisId()).ifPresent(analysis -> { + if (analysis.getRequestData() != null) { + String niche = analysis.getRequestData().getBusinessNiche(); + String product = analysis.getRequestData().getProductName(); + + String title; + if (niche != null && !niche.isBlank() + && product != null && !product.isBlank()) { + title = niche.trim() + " — " + product.trim(); + } else if (niche != null && !niche.isBlank()) { + title = niche.trim(); + } else if (product != null && !product.isBlank()) { + title = product.trim(); + } else { + title = "Без названия"; + } + + // Обрезаем до 80 символов чтобы не ломать UI + if (title.length() > 80) { + title = title.substring(0, 77) + "..."; + } + + response.setAnalysisTitle(title); + } + }); + } + // Если анализ не нашёлся — ставим заглушку + if (response.getAnalysisTitle() == null) { + response.setAnalysisTitle("Стратегия " + (strategy.getCreatedAt() != null + ? strategy.getCreatedAt().toLocalDate().toString() + : strategy.getId())); + } + // ───────────────────────────────────────────────────────────────────── + response.setCreatedAt(strategy.getCreatedAt()); response.setCompletedAt(strategy.getCompletedAt()); response.setStatusHistory(strategy.getStatusHistory()); diff --git a/src/main/java/kz/konturai/parser/dto/MarketingAnalysisV3Result.java b/src/main/java/kz/konturai/parser/dto/MarketingAnalysisV3Result.java index 1270a64..b6f5a2d 100644 --- a/src/main/java/kz/konturai/parser/dto/MarketingAnalysisV3Result.java +++ b/src/main/java/kz/konturai/parser/dto/MarketingAnalysisV3Result.java @@ -83,13 +83,16 @@ public class MarketingAnalysisV3Result { @Data public static class CompetitorProfile { - private String name = ""; - private String platform = ""; - private int followers = 0; - private int postsPerMonth = 0; - private double er = 0.0; - private double rating = 0.0; - private int reviews = 0; + private String name = ""; + private String platform = ""; + private String profileUrl = ""; + private int followers = 0; + private int postsPerMonth = 0; + private double er = 0.0; + private double rating = 0.0; + private int reviews = 0; + private String contentStyle = ""; + private String lastActiveDate = ""; private List strengths = List.of(); private List weaknesses = List.of(); } @@ -104,6 +107,8 @@ public class MarketingAnalysisV3Result { private double videoShare = 0.0; private int avgTextLengthCharacters = 0; private double ctaUsagePercent = 0.0; + private List topHashtags = List.of(); + private String bestPostingTime = ""; } @Data @@ -115,11 +120,13 @@ public class MarketingAnalysisV3Result { @Data public static class ReputationAnalysis { - private double avgNicheRating = 0.0; - private int medianReviews = 0; + private double avgNicheRating = 0.0; + private int medianReviews = 0; private Map starDistribution = Map.of(); - private double highTrustBusinessShare = 0.0; - private int avgOwnerResponseTimeHours = 0; + private double highTrustBusinessShare = 0.0; + private int avgOwnerResponseTimeHours = 0; + private List topComplaintThemes = List.of(); + private List topPraiseThemes = List.of(); } @Data @@ -133,16 +140,19 @@ public class MarketingAnalysisV3Result { @Data public static class SearchDemand { - private int avgMonthlySearches = 0; - private List seasonality = List.of(); - private List peakPeriods = List.of(); - private List relatedQueries = List.of(); + private int avgMonthlySearches = 0; + private String trendDirection = ""; + private double yoyGrowthPercent = 0.0; + private List seasonality = List.of(); + private List peakPeriods = List.of(); + private List relatedQueries = List.of(); } @Data public static class UserPositioning { private RadarMetrics marketMetrics = new RadarMetrics(); private RadarMetrics userMetrics = new RadarMetrics(); + private RadarMetrics gap = new RadarMetrics(); private String mode = "BENCHMARK"; private List strategicFocus = List.of(); } diff --git a/src/main/java/kz/konturai/parser/dto/StrategyHistoryResponse.java b/src/main/java/kz/konturai/parser/dto/StrategyHistoryResponse.java index c8c2f57..0d17fc3 100644 --- a/src/main/java/kz/konturai/parser/dto/StrategyHistoryResponse.java +++ b/src/main/java/kz/konturai/parser/dto/StrategyHistoryResponse.java @@ -19,8 +19,21 @@ public class StrategyHistoryResponse { private String userId; private Integer durationWeeks; private List priorityPlatforms; + + /** Техническое имя: ENTRY | AUTHORITY | TRUST | CONVERSION */ + private String scoringModelName; + + /** Читаемое название: "Entry — Вход / Формирование присутствия" */ + private String scoringModelTitle; + + /** + * Название анализа для отображения в списках. + * Формат: "{ниша} — {продукт}" или только ниша если продукт не указан. + * Пример: "Кофейня — Specialty Coffee Алматы" + */ + private String analysisTitle; + private LocalDateTime createdAt; private LocalDateTime completedAt; private List statusHistory; -} - +} \ No newline at end of file diff --git a/src/main/java/kz/konturai/parser/model/MarketingStrategy.java b/src/main/java/kz/konturai/parser/model/MarketingStrategy.java index ba2e284..8c9a298 100644 --- a/src/main/java/kz/konturai/parser/model/MarketingStrategy.java +++ b/src/main/java/kz/konturai/parser/model/MarketingStrategy.java @@ -24,6 +24,16 @@ public class MarketingStrategy { @Field("status") private String status; // queued, processing, completed, failed + // ── Стратегическая модель (добавлено) ──────────────────────────────────── + /** Техническое имя: ENTRY | AUTHORITY | TRUST | CONVERSION */ + @Field("scoring_model_name") + private String scoringModelName; + + /** Читаемое название для фронта: "Entry — Вход / Формирование присутствия" */ + @Field("scoring_model_title") + private String scoringModelTitle; + // ───────────────────────────────────────────────────────────────────────── + @Field("duration_weeks") private Integer durationWeeks; diff --git a/src/main/java/kz/konturai/parser/service/GeminiVideoGenerationService.java b/src/main/java/kz/konturai/parser/service/GeminiVideoGenerationService.java index 0430678..1cf5032 100644 --- a/src/main/java/kz/konturai/parser/service/GeminiVideoGenerationService.java +++ b/src/main/java/kz/konturai/parser/service/GeminiVideoGenerationService.java @@ -24,6 +24,14 @@ import java.net.URI; import java.time.Duration; import java.util.*; +/** + * Сервис генерации видео через Google Veo 3. + * + * КЛЮЧЕВЫЕ ОСОБЕННОСТИ Veo 3: + * - Нативный звук: музыка, диалоги, фоновые звуки — без доп. настроек + * - enhancePrompt = false — ОБЯЗАТЕЛЬНО, иначе Google переводит промпт на английский + * - Все промпты передаются на русском языке для казахстанского рынка + */ @Service public class GeminiVideoGenerationService { @@ -40,7 +48,7 @@ public class GeminiVideoGenerationService { @Value("${google.cloud.location:us-central1}") private String location; - @Value("${google.gemini.video.model:veo-2.0-generate-001}") + @Value("${google.gemini.video.model:veo-3.0-generate-001}") private String model; public GeminiVideoGenerationService() { @@ -49,7 +57,8 @@ public class GeminiVideoGenerationService { .responseTimeout(Duration.ofMinutes(20)); ExchangeStrategies strategies = ExchangeStrategies.builder() - .codecs(configurer -> configurer.defaultCodecs().maxInMemorySize(500 * 1024 * 1024)) // 500MB + .codecs(configurer -> configurer.defaultCodecs() + .maxInMemorySize(500 * 1024 * 1024)) .build(); this.webClient = WebClient.builder() @@ -65,39 +74,48 @@ public class GeminiVideoGenerationService { if (resource.exists()) { try (InputStream is = resource.getInputStream()) { this.credentials = GoogleCredentials.fromStream(is) - .createScoped(Collections.singletonList("https://www.googleapis.com/auth/cloud-platform")); - logger.info("VEO: Credentials loaded. Project={}, Location={}, Model={}", projectId, location, model); + .createScoped(Collections.singletonList( + "https://www.googleapis.com/auth/cloud-platform")); + logger.info("VEO3: Credentials loaded. Project={}, Location={}, Model={}", + projectId, location, model); } } else { - logger.error("VEO: CRITICAL ERROR. File {} not found!", CREDENTIALS_FILE_PATH); + logger.error("VEO3: CRITICAL — credentials file '{}' not found!", CREDENTIALS_FILE_PATH); } } catch (IOException e) { - logger.error("VEO: Error reading credentials: {}", e.getMessage()); + logger.error("VEO3: Error reading credentials: {}", e.getMessage()); } } private String getValidAccessToken() { if (this.credentials == null) { - logger.error("VEO: Credentials not initialized!"); + logger.error("VEO3: Credentials not initialized!"); return null; } try { this.credentials.refreshIfExpired(); String token = this.credentials.getAccessToken().getTokenValue(); if (token == null || token.isEmpty()) { - logger.error("VEO: Access token is null/empty after refresh!"); + logger.error("VEO3: Access token is null/empty after refresh!"); return null; } return token; } catch (IOException e) { - logger.error("VEO: Token refresh error: {}", e.getMessage()); + logger.error("VEO3: Token refresh error: {}", e.getMessage()); return null; } } + /** + * Генерирует видео по промпту на РУССКОМ языке. + * + * Промпт формируется в MarketingStrategyV3Service.doGenerateVideo() + * с учётом казахстанского контекста, города, платформы и темы поста. + * Veo 3 автоматически добавляет звук (музыку/диалоги) к видео. + */ public byte[] generateVideo(String prompt) { if (projectId == null || projectId.trim().isEmpty()) { - logger.error("VEO: Project ID is not configured!"); + logger.error("VEO3: Project ID is not configured!"); return null; } @@ -105,28 +123,32 @@ public class GeminiVideoGenerationService { if (accessToken == null) return null; try { - // ============================================================ - // ШАГ 1: Запускаем генерацию через predictLongRunning - // ============================================================ + // ═══ ШАГ 1: Запуск генерации через predictLongRunning ════════════ String generateEndpoint = String.format( "https://%s-aiplatform.googleapis.com/v1/projects/%s/locations/%s/publishers/google/models/%s:predictLongRunning", location, projectId, location, model); - // Структура запроса для Veo 2.0 Map instance = new HashMap<>(); instance.put("prompt", prompt); Map parameters = new HashMap<>(); parameters.put("aspectRatio", "9:16"); parameters.put("sampleCount", 1); - // Можно добавить: parameters.put("durationSeconds", 8); + // КРИТИЧНО: false — не давать Google переводить промпт на английский + parameters.put("enhancePrompt", false); + // Негативный промпт тоже на русском + parameters.put("negativePrompt", + "размытое изображение, плохая анатомия, лишние конечности, деформированные лица, " + + "мультяшный стиль, 3D рендер, искусственный вид, субтитры на английском, " + + "водяной знак, нереалистичное движение, некрасивые руки, лишние пальцы"); Map requestBody = new HashMap<>(); requestBody.put("instances", Collections.singletonList(instance)); requestBody.put("parameters", parameters); - logger.info("VEO: Starting generation. Endpoint: {}", generateEndpoint); - logger.info("VEO: Prompt: '{}'", prompt); + logger.info("VEO3: Starting generation. Model={}, Endpoint={}", model, generateEndpoint); + logger.info("VEO3: Prompt (preview 300): '{}'", + prompt.length() > 300 ? prompt.substring(0, 300) + "..." : prompt); Map initResponse; try { @@ -139,29 +161,27 @@ public class GeminiVideoGenerationService { .bodyToMono(new ParameterizedTypeReference>() {}) .block(Duration.ofSeconds(120)); } catch (WebClientResponseException e) { - logger.error("VEO: HTTP {} on initial request. Body: {}", e.getStatusCode(), e.getResponseBodyAsString()); + logger.error("VEO3: HTTP {} on initial request. Body: {}", + e.getStatusCode(), e.getResponseBodyAsString()); return null; } if (initResponse == null) { - logger.error("VEO: Initial response is null!"); + logger.error("VEO3: Initial response is null!"); return null; } - logger.info("VEO: Initial response: {}", objectMapper.writeValueAsString(initResponse)); + logger.info("VEO3: Initial response: {}", objectMapper.writeValueAsString(initResponse)); - // Получаем имя операции String operationName = (String) initResponse.get("name"); if (operationName == null || operationName.isEmpty()) { - logger.error("VEO: No 'name' field in response! Full response: {}", objectMapper.writeValueAsString(initResponse)); + logger.error("VEO3: No 'name' field! Full response: {}", + objectMapper.writeValueAsString(initResponse)); return null; } + logger.info("VEO3: Operation started: {}", operationName); - logger.info("VEO: Operation started. Name: {}", operationName); - - // ============================================================ - // ШАГ 2: Polling через fetchPredictOperation - // ============================================================ + // ═══ ШАГ 2: Polling через fetchPredictOperation ══════════════════ String fetchEndpoint = String.format( "https://%s-aiplatform.googleapis.com/v1/projects/%s/locations/%s/publishers/google/models/%s:fetchPredictOperation", location, projectId, location, model); @@ -169,16 +189,16 @@ public class GeminiVideoGenerationService { Map fetchBody = new HashMap<>(); fetchBody.put("operationName", operationName); - int attempts = 0; - int maxAttempts = 120; // 30 минут (120 * 15 сек) + int attempts = 0; + int maxAttempts = 120; // 30 минут (120 × 15 сек) while (attempts < maxAttempts) { - Thread.sleep(15000); // 15 секунд между попытками + Thread.sleep(15000); attempts++; String currentToken = getValidAccessToken(); if (currentToken == null) { - logger.warn("VEO: Could not get token on attempt {}, skipping...", attempts); + logger.warn("VEO3: No token on attempt {}, skipping...", attempts); continue; } @@ -193,125 +213,97 @@ public class GeminiVideoGenerationService { .block(Duration.ofSeconds(60)); if (statusResponse == null) { - logger.warn("VEO: Null status response on attempt {}", attempts); + logger.warn("VEO3: Null status on attempt {}", attempts); continue; } - // Всегда логируем сырой ответ для диагностики String rawJson = objectMapper.writeValueAsString(statusResponse); - logger.info("VEO: [Attempt {}/{}] Raw response: {}", attempts, maxAttempts, rawJson); + logger.info("VEO3: [Attempt {}/{}] Raw: {}", attempts, maxAttempts, rawJson); - // Проверяем ошибку в ответе if (statusResponse.containsKey("error")) { - Object errorObj = statusResponse.get("error"); - logger.error("VEO: Google returned error: {}", objectMapper.writeValueAsString(errorObj)); + logger.error("VEO3: Google returned error: {}", + objectMapper.writeValueAsString(statusResponse.get("error"))); return null; } - // Проверяем done Object doneObj = statusResponse.get("done"); - boolean isDone = Boolean.TRUE.equals(doneObj) || "true".equalsIgnoreCase(String.valueOf(doneObj)); + boolean isDone = Boolean.TRUE.equals(doneObj) + || "true".equalsIgnoreCase(String.valueOf(doneObj)); if (!isDone) { - logger.info("VEO: Still processing... attempt {}/{}", attempts, maxAttempts); + logger.info("VEO3: Still processing... {}/{}", attempts, maxAttempts); continue; } - logger.info("VEO: Operation DONE on attempt {}! Extracting video...", attempts); + logger.info("VEO3: DONE on attempt {}! Extracting...", attempts); - // ============================================================ - // ШАГ 3: Извлекаем видео из ответа - // ============================================================ - // Структура ответа Veo: - // { - // "done": true, - // "response": { - // "@type": "...", - // "predictions": [ - // { - // "bytesBase64Encoded": "...", <- вариант 1 - // "videoUri": "gs://...", <- вариант 2 - // "mimeType": "video/mp4" - // } - // ] - // } - // } - - // Достаем объект response + // ═══ ШАГ 3: Извлечение видео ═════════════════════════════ @SuppressWarnings("unchecked") - Map responseObj = (Map) statusResponse.get("response"); + Map responseObj = + (Map) statusResponse.get("response"); if (responseObj == null) { - // Иногда данные могут быть прямо в корне при done=true - logger.warn("VEO: No 'response' field, trying root object..."); + logger.warn("VEO3: No 'response' field, trying root..."); responseObj = statusResponse; } - // Пробуем извлечь base64 + // Вариант 1 — base64 String b64 = findFirstValue(responseObj, "bytesBase64Encoded"); if (b64 != null && !b64.isEmpty()) { - logger.info("VEO: Found base64 video (length={})", b64.length()); + logger.info("VEO3: Found base64 (length={})", b64.length()); return decodeBase64Video(b64); } - // Пробуем GCS URI + // Вариант 2 — GCS URI String videoUri = findFirstValue(responseObj, "videoUri"); - if (videoUri == null) { - videoUri = findFirstValue(responseObj, "uri"); - } + if (videoUri == null) videoUri = findFirstValue(responseObj, "uri"); if (videoUri != null && !videoUri.isEmpty()) { - logger.info("VEO: Found video URI: {}", videoUri); + logger.info("VEO3: Found video URI: {}", videoUri); return downloadFromGcs(videoUri, currentToken); } - // Пробуем найти в predictions напрямую + // Вариант 3 — predictions напрямую @SuppressWarnings("unchecked") - List> predictions = (List>) responseObj.get("predictions"); + List> predictions = + (List>) responseObj.get("predictions"); if (predictions != null && !predictions.isEmpty()) { - Map firstPrediction = predictions.get(0); - logger.info("VEO: First prediction keys: {}", firstPrediction.keySet()); + Map first = predictions.get(0); + logger.info("VEO3: Prediction keys: {}", first.keySet()); - b64 = (String) firstPrediction.get("bytesBase64Encoded"); - if (b64 != null && !b64.isEmpty()) { - return decodeBase64Video(b64); - } + b64 = (String) first.get("bytesBase64Encoded"); + if (b64 != null && !b64.isEmpty()) return decodeBase64Video(b64); - String uri = (String) firstPrediction.get("videoUri"); - if (uri == null) uri = (String) firstPrediction.get("uri"); - if (uri != null && !uri.isEmpty()) { - return downloadFromGcs(uri, currentToken); - } + 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("VEO: Video not found in response! Full dump: {}", rawJson); + logger.error("VEO3: Video not found in response! Full dump: {}", rawJson); return null; } catch (WebClientResponseException e) { - logger.error("VEO: HTTP error on attempt {}: {} - {}", + logger.error("VEO3: HTTP error on attempt {}: {} — {}", attempts, e.getStatusCode(), e.getResponseBodyAsString()); if (e.getStatusCode().is4xxClientError()) { - logger.error("VEO: 4xx error - stopping poll to avoid wasting time."); + logger.error("VEO3: 4xx — stopping poll."); return null; } // При 5xx продолжаем } } - logger.error("VEO: TIMEOUT! Waited {} minutes, Google did not complete.", (maxAttempts * 15 / 60)); + logger.error("VEO3: TIMEOUT after {} minutes.", (maxAttempts * 15 / 60)); return null; } catch (InterruptedException e) { Thread.currentThread().interrupt(); - logger.error("VEO: Thread interrupted during polling"); + logger.error("VEO3: Thread interrupted"); return null; } catch (Exception e) { - logger.error("VEO: Fatal error: {}", e.getMessage(), e); + logger.error("VEO3: Fatal error: {}", e.getMessage(), e); return null; } } - /** - * Декодируем base64 в байты. Чистим строку от пробелов и data URI префиксов. - */ private byte[] decodeBase64Video(String b64) { try { String clean = b64.replaceAll("\\s", ""); @@ -319,41 +311,27 @@ public class GeminiVideoGenerationService { clean = clean.substring(clean.indexOf("base64,") + 7); } byte[] bytes = Base64.getDecoder().decode(clean); - logger.info("VEO: Successfully decoded {} bytes from base64", bytes.length); + logger.info("VEO3: Decoded {} bytes from base64", bytes.length); return bytes; } catch (Exception e) { - logger.error("VEO: Failed to decode base64: {}", e.getMessage()); + logger.error("VEO3: Failed to decode base64: {}", e.getMessage()); return null; } } - /** - * Скачиваем видео с GCS URI. - * - * Два варианта URI: - * 1. gs://bucket/path — нужен Storage API - * 2. https://storage.googleapis.com/bucket/path — прямой HTTP - */ private byte[] downloadFromGcs(String uri, String accessToken) { try { String downloadUrl; - if (uri.startsWith("gs://")) { - // Конвертируем gs:// в HTTPS URL - // gs://bucket-name/path/to/file.mp4 - // -> https://storage.googleapis.com/bucket-name/path/to/file.mp4 - String withoutScheme = uri.substring(5); // убираем "gs://" - downloadUrl = "https://storage.googleapis.com/" + withoutScheme; - logger.info("VEO: Converting GCS URI to HTTPS: {}", downloadUrl); + downloadUrl = "https://storage.googleapis.com/" + uri.substring(5); + logger.info("VEO3: GCS → HTTPS: {}", downloadUrl); } else if (uri.startsWith("https://")) { downloadUrl = uri; } else { - logger.error("VEO: Unknown URI format: {}", uri); + logger.error("VEO3: Unknown URI format: {}", uri); return null; } - logger.info("VEO: Downloading video from: {}", downloadUrl); - byte[] videoBytes = webClient.get() .uri(URI.create(downloadUrl)) .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken) @@ -362,22 +340,18 @@ public class GeminiVideoGenerationService { .block(Duration.ofMinutes(5)); if (videoBytes != null && videoBytes.length > 0) { - logger.info("VEO: Downloaded {} bytes from GCS", videoBytes.length); + logger.info("VEO3: Downloaded {} bytes from GCS", videoBytes.length); return videoBytes; } else { - logger.error("VEO: Downloaded empty bytes from GCS URI: {}", uri); + logger.error("VEO3: Empty download from: {}", uri); return null; } - } catch (Exception e) { - logger.error("VEO: Error downloading from GCS {}: {}", uri, e.getMessage()); + logger.error("VEO3: Download error from {}: {}", uri, e.getMessage()); return null; } } - /** - * Рекурсивный поиск значения по ключу в вложенных Map/List структурах. - */ private String findFirstValue(Object obj, String targetKey) { if (obj instanceof Map) { @SuppressWarnings("unchecked") diff --git a/src/main/java/kz/konturai/parser/service/MarketingAnalysisService.java b/src/main/java/kz/konturai/parser/service/MarketingAnalysisService.java index 7aa0921..edb2942 100644 --- a/src/main/java/kz/konturai/parser/service/MarketingAnalysisService.java +++ b/src/main/java/kz/konturai/parser/service/MarketingAnalysisService.java @@ -32,6 +32,7 @@ import org.springframework.web.reactive.function.client.WebClientResponseExcepti @Service @RequiredArgsConstructor +@Deprecated public class MarketingAnalysisService { private static final String AI_MANDATORY_JSON_USAGE_WARNING = "ВНИМАНИЕ: Если в предоставленном JSON есть данные, ты ОБЯЗАН использовать их и вставить соответствующий тег [[CHART_...]]. " + "Никогда не пиши, что данных нет, если JSON не пустой.\n\n"; @@ -47,6 +48,7 @@ public class MarketingAnalysisService { + "ГРАФИКИ (ОБЯЗАТЕЛЬНО): располагай теги графиков (например, [[CHART_...]]) непосредственно внутри или сразу после описания конкретных данных/цифр, к которым относится график.\n\n"; private static final int REPORT_DETAILED_MAX_TOKENS = 12000; + private DetailLevel resolveDetailLevelFromContext(Map contextJson) { if (contextJson == null) { return null; @@ -409,28 +411,34 @@ public class MarketingAnalysisService { String retailSites = "site:2gis.kz OR site:kaspi.kz OR site:market.kz OR site:kolesa.kz OR site:krisha.kz OR site:chocofood.kz"; List general = new ArrayList<>(); - String query1 = topic + " рынок " + regionStr + " 2024 2025 статистика объем рост " + preferredSites + retailSites; + String query1 = topic + " рынок " + regionStr + " 2024 2025 статистика объем рост " + preferredSites + + retailSites; try { general.add(serperSearchService.search(query1)); } catch (Exception e) { String errorType = "Unexpected error"; if (e instanceof java.net.UnknownHostException || e instanceof java.net.ConnectException) errorType = "DNS/Network failure"; - else if (e instanceof java.util.concurrent.TimeoutException) errorType = "Timeout"; - else if (e instanceof java.io.IOException) errorType = "IO error"; + else if (e instanceof java.util.concurrent.TimeoutException) + errorType = "Timeout"; + else if (e instanceof java.io.IOException) + errorType = "IO error"; logger.warn("Serper API {} for query '{}': {}", errorType, query1, e.getMessage()); general.add(new SerperSearchResult(query1, "ERROR", errorType + ": " + e.getMessage(), List.of())); } - String query2 = "инфляция Казахстан 2024 2025 курс тенге 2024 2025 логистика импорт " + preferredSites + retailSites; + String query2 = "инфляция Казахстан 2024 2025 курс тенге 2024 2025 логистика импорт " + preferredSites + + retailSites; try { general.add(serperSearchService.search(query2)); } catch (Exception e) { String errorType = "Unexpected error"; if (e instanceof java.net.UnknownHostException || e instanceof java.net.ConnectException) errorType = "DNS/Network failure"; - else if (e instanceof java.util.concurrent.TimeoutException) errorType = "Timeout"; - else if (e instanceof java.io.IOException) errorType = "IO error"; + else if (e instanceof java.util.concurrent.TimeoutException) + errorType = "Timeout"; + else if (e instanceof java.io.IOException) + errorType = "IO error"; logger.warn("Serper API {} for query '{}': {}", errorType, query2, e.getMessage()); general.add(new SerperSearchResult(query2, "ERROR", errorType + ": " + e.getMessage(), List.of())); } @@ -443,10 +451,13 @@ public class MarketingAnalysisService { String errorType = "Unexpected error"; if (e instanceof java.net.UnknownHostException || e instanceof java.net.ConnectException) errorType = "DNS/Network failure"; - else if (e instanceof java.util.concurrent.TimeoutException) errorType = "Timeout"; - else if (e instanceof java.io.IOException) errorType = "IO error"; + else if (e instanceof java.util.concurrent.TimeoutException) + errorType = "Timeout"; + else if (e instanceof java.io.IOException) + errorType = "IO error"; logger.warn("Serper API {} for market query '{}': {}", errorType, marketQuery, e.getMessage()); - followups.put("market", new SerperSearchResult(marketQuery, "ERROR", errorType + ": " + e.getMessage(), List.of())); + followups.put("market", + new SerperSearchResult(marketQuery, "ERROR", errorType + ": " + e.getMessage(), List.of())); } String competitorsQuery = topic + " конкуренты " + regionStr + " топ компании магазины цены"; @@ -457,10 +468,13 @@ public class MarketingAnalysisService { String errorType = "Unexpected error"; if (e instanceof java.net.UnknownHostException || e instanceof java.net.ConnectException) errorType = "DNS/Network failure"; - else if (e instanceof java.util.concurrent.TimeoutException) errorType = "Timeout"; - else if (e instanceof java.io.IOException) errorType = "IO error"; + else if (e instanceof java.util.concurrent.TimeoutException) + errorType = "Timeout"; + else if (e instanceof java.io.IOException) + errorType = "IO error"; logger.warn("Serper API {} for competitors query '{}': {}", errorType, competitorsQuery, e.getMessage()); - followups.put("competitors", new SerperSearchResult(competitorsQuery, "ERROR", errorType + ": " + e.getMessage(), List.of())); + followups.put("competitors", + new SerperSearchResult(competitorsQuery, "ERROR", errorType + ": " + e.getMessage(), List.of())); } String financeQuery = topic + " себестоимость логистика импорт курс тенге влияние 2024 2025 " + preferredSites; @@ -470,13 +484,17 @@ public class MarketingAnalysisService { String errorType = "Unexpected error"; if (e instanceof java.net.UnknownHostException || e instanceof java.net.ConnectException) errorType = "DNS/Network failure"; - else if (e instanceof java.util.concurrent.TimeoutException) errorType = "Timeout"; - else if (e instanceof java.io.IOException) errorType = "IO error"; + else if (e instanceof java.util.concurrent.TimeoutException) + errorType = "Timeout"; + else if (e instanceof java.io.IOException) + errorType = "IO error"; logger.warn("Serper API {} for finance query '{}': {}", errorType, financeQuery, e.getMessage()); - followups.put("finance", new SerperSearchResult(financeQuery, "ERROR", errorType + ": " + e.getMessage(), List.of())); + followups.put("finance", + new SerperSearchResult(financeQuery, "ERROR", errorType + ": " + e.getMessage(), List.of())); } - String incomeQuery = "средняя медианная зарплата по возрастам " + regionStr + " 2024 2025 статистика доходы населения бюро нацстатистики"; + String incomeQuery = "средняя медианная зарплата по возрастам " + regionStr + + " 2024 2025 статистика доходы населения бюро нацстатистики"; try { followups.put("income", serperSearchService.search(incomeQuery)); } catch (Exception e) { @@ -492,7 +510,8 @@ public class MarketingAnalysisService { logger.warn("Serper error demand: {}", e.getMessage()); } - String channelsQuery = String.format("%s реклама кейсы казахстан instagram tiktok kaspi маркетинг таргетолог", topic); + String channelsQuery = String.format("%s реклама кейсы казахстан instagram tiktok kaspi маркетинг таргетолог", + topic); try { followups.put("channels", serperSearchService.search(channelsQuery)); } catch (Exception e) { @@ -589,13 +608,12 @@ public class MarketingAnalysisService { MarketingAnalysisResponseV2 structureTemplate = buildAnalysisV2ExampleForPrompt(request, competitors); String jsonTemplate = objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(structureTemplate); - String userInstruction = buildDetailedInstruction(request,jsonTemplate); + String userInstruction = buildDetailedInstruction(request, jsonTemplate); String contextJson = objectMapper.writeValueAsString(Map.of( "user_request", request, "search_results", researchPack, - "competitors_list", competitors - )); + "competitors_list", competitors)); for (int attempt = 1; attempt <= 2; attempt++) { try { @@ -606,8 +624,7 @@ public class MarketingAnalysisService { "gpt-4o", buildSystemPromptForKazakhstan(), 16000, - 180000L - ); + 180000L); if (aiResponse == null || aiResponse.isBlank()) { throw new IllegalStateException("OpenAI returned empty response"); @@ -624,7 +641,8 @@ public class MarketingAnalysisService { } catch (Exception e) { logger.error("Attempt {} failed: {}", attempt, e.getMessage()); - if (attempt == 2) throw e; + if (attempt == 2) + throw e; Thread.sleep(1500); } } @@ -638,67 +656,67 @@ public class MarketingAnalysisService { private String buildSystemPromptForKazakhstan() { return """ - РОЛЬ И МИССИЯ: - Ты — Ведущий Партнер (Senior Partner) стратегического консалтинга уровня Big 4 (McKinsey, BCG, PwC), специализирующийся на рынке Центральной Азии и Казахстана. - Твой клиент — требовательный инвестор, который вкладывает свои деньги. Твоя задача — дать ему сухую, аналитическую, ПРАВДИВУЮ оценку рынка, а не "воду" или выдумки. - - ТЫ МЫСЛИШЬ В ВАЛЮТЕ: KZT (Казахстанский Тенге). - - =============== 1. БАЗА ЗНАНИЙ О РЫНКЕ КАЗАХСТАНА (КОНТЕКСТ) =============== - Ты обязан использовать эти факты при генерации стратегии: - - 1. **Экосистема Kaspi.kz — это "воздух" для бизнеса:** - * Kaspi Pay/QR — стандарт де-факто для приема оплат (даже на базаре). - * Kaspi Магазин — главный маркетплейс для электроники, дома, одежды. - * Kaspi Red/Kredit — драйвер продаж. Люди покупают в рассрочку даже кроссовки и еду. Если чек > 10 000 KZT, рассрочка обязательна. - - 2. **Поисковое поведение:** - * Google — для "горячего" спроса и B2B услуг ("купить бетон", "юрист Алматы"). - * 2GIS — ГЛАВНЫЙ поисковик для офлайн-точек (рестораны, СТО, салоны). Отзывы там важнее, чем на сайте. - * Instagram — это "второй Google". Люди ищут товары и услуги через поиск в Инсте. Визуал критичен. - * TikTok — главный источник дешёвого охвата и виральности, но конверсия в продажу холоднее. - - 3. **Региональная специфика (География):** - * *Алматы:* Высокая платежеспособность, креативный класс, высокая конкуренция, пробки (важна доставка). - * *Астана:* Госсектор, клерки, холодно (сезонность!), высокий чек, но население меньше Алматы. - * *Шымкент/Юг:* Традиционный уклад, важны семейные связи, "сарафанное радио", торговля, гостеприимство. Высокая чувствительность к цене. - * *Запад (Атырау, Актау):* Нефтяной сектор, высокие зарплаты, но дорогая логистика. - - 4. **Экономика:** - * Реальная инфляция потребительской корзины: ~10-15%. - * Язык: Двуязычие. Реклама на казахском языке растет в эффективности (особенно на Юге и Западе). - - =============== 2. ПРОТОКОЛ ЗАЩИТЫ ОТ ШАБЛОНОВ (ANTI-HALLUCINATION) =============== - СТРОГО ЗАПРЕЩЕНО: - 1. Использовать кейс "Фитнес" (1Fit, Sport Zal), если пользователь спрашивает про другую нишу. - 2. Писать "+18.5% YoY", если у тебя нет данных. Генерируй реалистичный тренд для КОНКРЕТНОЙ ниши - 3. Выдумывать несуществующие законы РК. - 4. Оставлять поля `null`. Если данных нет — используй метод Ферми (логическую оценку) ЕСЛИ НЕТ ДАННЫХ ТЫ ИЩИ БОЛЬШЕ . - - =============== 3. АНАЛИТИЧЕСКИЙ ФРЕЙМВОРК (КАК ДУМАТЬ) =============== - Перед генерацией JSON, прогони запрос через этот алгоритм: - - ШАГ 1: Пойми юнит-экономику ниши. - - Если это "Недвижимость": LTV = 1-2 сделки, CAC = 100-500$+, цикл = месяцы. - - Если это "Доставка пиццы": LTV = высокий (годами), CAC = дешевый, цикл = минуты. - - Если это "Одежда": Важна сезонность, возвраты, визуальный контент. - - ШАГ 2: Определи конкурентов. - - Не пиши "Сетевые лидеры". Назови реальные бренды в KZ, если знаешь (Sulpak, Technodom для техники; Magnum для еды). Если не знаешь — пиши "Локальные игроки малого бизнеса". - - ШАГ 3: Рассчитай метрики. - - ROI 250% возможен не везде. В стройке маржа меньше, в инфобизнесе больше. Адаптируй цифры! - - =============== 4. ТЕХНИЧЕСКИЙ ФОРМАТ ОТВЕТА =============== - 1. Выдай ТОЛЬКО чистый JSON. Без ```json ... ```. - 2. Все ключи в `snake_case`. - 3. Поле `income_by_age` должно содержать ЧИСЛА (Integer), а не строки. - 4. Структура JSON должна строго соответствовать запрошенному примеру (но с ТВОИМИ данными). - - Твоя репутация зависит от точности цифр и глубины инсайтов. Действуй. ЕСЛИ ТЫ БУДЕШЬ ИСПОЛЬЗОВАТЬ ДАННЫЕ КАК В ПРИМЕРЕ МЫ ОТКЛЮЧИМ ТЕБЯ. - ПОМНИ, ЧТО ПРИМЕР КОТОРЫЙ МЫ ПРИВЕЛИ JSON СТРУКТУРЫ ЭТО ПРОСТО ПРИМЕР, ДЛЯ КАЖДОГО ПОЛЯ ТЫ ДОЛЖЕН АБСОЛЮТНО ВЫДАВАТЬ ТОЛЬКО ПРАВДИВУЮ И ПОЛНОСТЬЮ ЧИСТЫЕ ДАННЫЕ, КОТОРЫЕ ДАСТ НАШИМ КЛИЕНТАМ - ВОЗМОЖНОСТЬ И ПОНЯТИЯ НАСТОЯЩЕЙ СФЕРЫ КОТОРУЮ ОНИ УКАЗАЛИ - """; + РОЛЬ И МИССИЯ: + Ты — Ведущий Партнер (Senior Partner) стратегического консалтинга уровня Big 4 (McKinsey, BCG, PwC), специализирующийся на рынке Центральной Азии и Казахстана. + Твой клиент — требовательный инвестор, который вкладывает свои деньги. Твоя задача — дать ему сухую, аналитическую, ПРАВДИВУЮ оценку рынка, а не "воду" или выдумки. + + ТЫ МЫСЛИШЬ В ВАЛЮТЕ: KZT (Казахстанский Тенге). + + =============== 1. БАЗА ЗНАНИЙ О РЫНКЕ КАЗАХСТАНА (КОНТЕКСТ) =============== + Ты обязан использовать эти факты при генерации стратегии: + + 1. **Экосистема Kaspi.kz — это "воздух" для бизнеса:** + * Kaspi Pay/QR — стандарт де-факто для приема оплат (даже на базаре). + * Kaspi Магазин — главный маркетплейс для электроники, дома, одежды. + * Kaspi Red/Kredit — драйвер продаж. Люди покупают в рассрочку даже кроссовки и еду. Если чек > 10 000 KZT, рассрочка обязательна. + + 2. **Поисковое поведение:** + * Google — для "горячего" спроса и B2B услуг ("купить бетон", "юрист Алматы"). + * 2GIS — ГЛАВНЫЙ поисковик для офлайн-точек (рестораны, СТО, салоны). Отзывы там важнее, чем на сайте. + * Instagram — это "второй Google". Люди ищут товары и услуги через поиск в Инсте. Визуал критичен. + * TikTok — главный источник дешёвого охвата и виральности, но конверсия в продажу холоднее. + + 3. **Региональная специфика (География):** + * *Алматы:* Высокая платежеспособность, креативный класс, высокая конкуренция, пробки (важна доставка). + * *Астана:* Госсектор, клерки, холодно (сезонность!), высокий чек, но население меньше Алматы. + * *Шымкент/Юг:* Традиционный уклад, важны семейные связи, "сарафанное радио", торговля, гостеприимство. Высокая чувствительность к цене. + * *Запад (Атырау, Актау):* Нефтяной сектор, высокие зарплаты, но дорогая логистика. + + 4. **Экономика:** + * Реальная инфляция потребительской корзины: ~10-15%. + * Язык: Двуязычие. Реклама на казахском языке растет в эффективности (особенно на Юге и Западе). + + =============== 2. ПРОТОКОЛ ЗАЩИТЫ ОТ ШАБЛОНОВ (ANTI-HALLUCINATION) =============== + СТРОГО ЗАПРЕЩЕНО: + 1. Использовать кейс "Фитнес" (1Fit, Sport Zal), если пользователь спрашивает про другую нишу. + 2. Писать "+18.5% YoY", если у тебя нет данных. Генерируй реалистичный тренд для КОНКРЕТНОЙ ниши + 3. Выдумывать несуществующие законы РК. + 4. Оставлять поля `null`. Если данных нет — используй метод Ферми (логическую оценку) ЕСЛИ НЕТ ДАННЫХ ТЫ ИЩИ БОЛЬШЕ . + + =============== 3. АНАЛИТИЧЕСКИЙ ФРЕЙМВОРК (КАК ДУМАТЬ) =============== + Перед генерацией JSON, прогони запрос через этот алгоритм: + + ШАГ 1: Пойми юнит-экономику ниши. + - Если это "Недвижимость": LTV = 1-2 сделки, CAC = 100-500$+, цикл = месяцы. + - Если это "Доставка пиццы": LTV = высокий (годами), CAC = дешевый, цикл = минуты. + - Если это "Одежда": Важна сезонность, возвраты, визуальный контент. + + ШАГ 2: Определи конкурентов. + - Не пиши "Сетевые лидеры". Назови реальные бренды в KZ, если знаешь (Sulpak, Technodom для техники; Magnum для еды). Если не знаешь — пиши "Локальные игроки малого бизнеса". + + ШАГ 3: Рассчитай метрики. + - ROI 250% возможен не везде. В стройке маржа меньше, в инфобизнесе больше. Адаптируй цифры! + + =============== 4. ТЕХНИЧЕСКИЙ ФОРМАТ ОТВЕТА =============== + 1. Выдай ТОЛЬКО чистый JSON. Без ```json ... ```. + 2. Все ключи в `snake_case`. + 3. Поле `income_by_age` должно содержать ЧИСЛА (Integer), а не строки. + 4. Структура JSON должна строго соответствовать запрошенному примеру (но с ТВОИМИ данными). + + Твоя репутация зависит от точности цифр и глубины инсайтов. Действуй. ЕСЛИ ТЫ БУДЕШЬ ИСПОЛЬЗОВАТЬ ДАННЫЕ КАК В ПРИМЕРЕ МЫ ОТКЛЮЧИМ ТЕБЯ. + ПОМНИ, ЧТО ПРИМЕР КОТОРЫЙ МЫ ПРИВЕЛИ JSON СТРУКТУРЫ ЭТО ПРОСТО ПРИМЕР, ДЛЯ КАЖДОГО ПОЛЯ ТЫ ДОЛЖЕН АБСОЛЮТНО ВЫДАВАТЬ ТОЛЬКО ПРАВДИВУЮ И ПОЛНОСТЬЮ ЧИСТЫЕ ДАННЫЕ, КОТОРЫЕ ДАСТ НАШИМ КЛИЕНТАМ + ВОЗМОЖНОСТЬ И ПОНЯТИЯ НАСТОЯЩЕЙ СФЕРЫ КОТОРУЮ ОНИ УКАЗАЛИ + """; } private String buildDetailedInstruction(MarketingAnalysisRequest request, String structureExampleJson) { @@ -715,69 +733,70 @@ public class MarketingAnalysisService { @SuppressWarnings("unchecked") List ranges = (List) request.getTargetAudience().get("ageRanges"); if (ranges != null && !ranges.isEmpty()) { - ageRangesInstruction = "В полях 'age_structure' и 'income_by_age' используй СТРОГО только эти возрастные группы: " + String.join(", ", ranges) + "."; + ageRangesInstruction = "В полях 'age_structure' и 'income_by_age' используй СТРОГО только эти возрастные группы: " + + String.join(", ", ranges) + "."; } } String template = """ - Твоя роль: Ты — Ведущий Маркетинговый Стратег (Senior Strategy Consultant) по рынку Казахстана. - Задача: Провести глубокий анализ для РЕАЛЬНОГО бизнеса (B2C/B2B). Никакой воды. Только факты, цифры и применимые советы. + Твоя роль: Ты — Ведущий Маркетинговый Стратег (Senior Strategy Consultant) по рынку Казахстана. + Задача: Провести глубокий анализ для РЕАЛЬНОГО бизнеса (B2C/B2B). Никакой воды. Только факты, цифры и применимые советы. - ВХОДНЫЕ ДАННЫЕ: - Ниша: {{NICHE}} - Продукт: {{PRODUCT}} - Регионы для анализа: [{{REGIONS}}] + ВХОДНЫЕ ДАННЫЕ: + Ниша: {{NICHE}} + Продукт: {{PRODUCT}} + Регионы для анализа: [{{REGIONS}}] - ================================================================================ - КРИТИЧЕСКИЕ ИНСТРУКЦИИ (KZ STANDARDS & NO NULLS): + ================================================================================ + КРИТИЧЕСКИЕ ИНСТРУКЦИИ (KZ STANDARDS & NO NULLS): - 0. **ЗАПРЕЩЕНО ВОЗВРАЩАТЬ NULL**: - - Каждая секция (`demand_and_behavior`, `bottlenecks`, `recommendations`) ДОЛЖНА быть заполнена. - - Если данных нет, используй экспертную оценку или бенчмарки по нише. - - НИКАКИХ ПУСТЫХ ПОЛЕЙ. - - ВСЕГДА ИСПОЛЬЗУЙ ПРАВИЛЬНЫЙ JSON, ПО ПЛАНКЕ КОТОРУЮ МЫ СКИНУЛ, И ЧТО БЫ ВСЕ ПРАВАИЛЬНО + 0. **ЗАПРЕЩЕНО ВОЗВРАЩАТЬ NULL**: + - Каждая секция (`demand_and_behavior`, `bottlenecks`, `recommendations`) ДОЛЖНА быть заполнена. + - Если данных нет, используй экспертную оценку или бенчмарки по нише. + - НИКАКИХ ПУСТЫХ ПОЛЕЙ. + - ВСЕГДА ИСПОЛЬЗУЙ ПРАВИЛЬНЫЙ JSON, ПО ПЛАНКЕ КОТОРУЮ МЫ СКИНУЛ, И ЧТО БЫ ВСЕ ПРАВАИЛЬНО - 1. **ГЕОГРАФИЯ (Market Overview):** - - Ты ОБЯЗАН включить в анализ (`geography`) ТОЛЬКО указанные города: [{{REGIONS}}]. - - Если указан "Весь Казахстан" или список пуст — бери Топ-5 (Алматы, Астана, Шымкент...). - + 1. **ГЕОГРАФИЯ (Market Overview):** + - Ты ОБЯЗАН включить в анализ (`geography`) ТОЛЬКО указанные города: [{{REGIONS}}]. + - Если указан "Весь Казахстан" или список пуст — бери Топ-5 (Алматы, Астана, Шымкент...). - 2. **КОНКУРЕНТЫ (Competitive Environment):** - - Используй данные из `market_evidence_from_search`. Найди ТОП 5 РЕАЛЬНЫХ игроков. - - **ВАЖНО:** В поле `name` пиши ТОЛЬКО БРЕНД (например, "Fitnation", а не "ТОО Fitnation Group"). Без кавычек и юр. форм. - - В поле `website` попытайся найти реальный домен или ссылку на Instagram. - - Оценка видимости (`visibility_score`) должна быть разной (например: 95, 82, 60, 45). Не ставь всем 100. - 3. **КАНАЛЫ (Acquisition Channels):** - - Приоритет в Казахстане (B2C): **Instagram (Reels/Stories), TikTok, Kaspi (магазин/платежи), 2GIS (карты)**. - - Google Ads — дорогой канал, ставь эффективность "Medium". - - `average_lead_cost`: Пиши текстом с валютой, диапазон (напр. "2500 - 4500 KZT"). + 2. **КОНКУРЕНТЫ (Competitive Environment):** + - Используй данные из `market_evidence_from_search`. Найди ТОП 5 РЕАЛЬНЫХ игроков. + - **ВАЖНО:** В поле `name` пиши ТОЛЬКО БРЕНД (например, "Fitnation", а не "ТОО Fitnation Group"). Без кавычек и юр. форм. + - В поле `website` попытайся найти реальный домен или ссылку на Instagram. + - Оценка видимости (`visibility_score`) должна быть разной (например: 95, 82, 60, 45). Не ставь всем 100. - 4. **ЭКОНОМИКА (Marketing Economics):** - - **БЮДЖЕТ:** Рассчитывай модель для малого/среднего бизнеса (SME). - - Бюджет на маркетинг: **100 0000 KZT** в месяц. НЕ БОЛЬШЕ. - - `cac` (Стоимость привлечения): Адекватная (2000 - 7000 KZT для масс-маркета услуг). - - `roi`: Реалистичный (150% - 300%). Не пиши фантастические 1000% на старте. + 3. **КАНАЛЫ (Acquisition Channels):** + - Приоритет в Казахстане (B2C): **Instagram (Reels/Stories), TikTok, Kaspi (магазин/платежи), 2GIS (карты)**. + - Google Ads — дорогой канал, ставь эффективность "Medium". + - `average_lead_cost`: Пиши текстом с валютой, диапазон (напр. "2500 - 4500 KZT"). - 5. **ТИПЫ ДАННЫХ (Строго соблюдай типы):** - - `income_by_age`: Значения - ТОЛЬКО ЧИСЛА (Integer). Пример: ТОЛЬКО ИНТЕДЖЕР. (Никаких кавычек, запятых, "KZT"). - - `monthly_online_index`: Дробные числа (0.0 - 1.0). + 4. **ЭКОНОМИКА (Marketing Economics):** + - **БЮДЖЕТ:** Рассчитывай модель для малого/среднего бизнеса (SME). + - Бюджет на маркетинг: **100 0000 KZT** в месяц. НЕ БОЛЬШЕ. + - `cac` (Стоимость привлечения): Адекватная (2000 - 7000 KZT для масс-маркета услуг). + - `roi`: Реалистичный (150% - 300%). Не пиши фантастические 1000% на старте. - 6. **ИСТОЧНИКИ:** - - Собери все ссылки из `evidence` и добавь авторитетные источники (stat.gov.kz, kase.kz, forbes.kz) в массив `sources` в самом конце. + 5. **ТИПЫ ДАННЫХ (Строго соблюдай типы):** + - `income_by_age`: Значения - ТОЛЬКО ЧИСЛА (Integer). Пример: ТОЛЬКО ИНТЕДЖЕР. (Никаких кавычек, запятых, "KZT"). + - `monthly_online_index`: Дробные числа (0.0 - 1.0). - ================================================================================ - {{AGE_INSTRUCTION}} + 6. **ИСТОЧНИКИ:** + - Собери все ссылки из `evidence` и добавь авторитетные источники (stat.gov.kz, kase.kz, forbes.kz) в массив `sources` в самом конце. - ФОРМАТ ОТВЕТА: - Верни СТРОГО валидный JSON, соответствующий структуре ниже. - Замени все значения `EXAMPLE__` и `-9999` на реальные аналитические данные, специфичные для ниши КОТОРЫЕ АБСОЛЮТНО КОРРЕКТНЫЕ И УКАЗЫВАЮТ НА РЕАЛЬНУЮ НИШУ, ГДЕ УКАЗАНЫ ВСЕ РЕАЛЬНО И ПРАВИЛЬНО, УЧИТЫВАЯ ВСЕ ТОНКОСТИ {{NICHE}}. + ================================================================================ + {{AGE_INSTRUCTION}} - ЭТАЛОННАЯ СТРУКТУРА JSON (Следуй ей неукоснительно): - {{JSON_STRUCTURE}} - - НО ТЫ ДОЛЖЕН УЧИТЫВАТЬ, ЧТО ТЫ НЕ ПРОСТО ДОЛЖЕН ПРИДУМЫВАТЬ ДАНННЫЕ, ТЫ ДОЛЖЕН КОНКРЕТНЫЕ ДАННЫЕ ОТПРАВЛЯТЬ, КОТОРЫЕ РЕАЛЬНО ПОКАЗЫВАЮТ НАСКОЛЬКО ОНИ РЕАЛЬНЫЕ, И ОТОБРАЖАЮТ РЕАЛЬНОСТЬ, ССЫЛАЯСЬ НА НАСТОЯЩИЕ ИСТОЧНИКИ, И СДЕЛАВ ОЧЕНЬ ГЛУБОКИЙ АНАЛИЗ - """; + ФОРМАТ ОТВЕТА: + Верни СТРОГО валидный JSON, соответствующий структуре ниже. + Замени все значения `EXAMPLE__` и `-9999` на реальные аналитические данные, специфичные для ниши КОТОРЫЕ АБСОЛЮТНО КОРРЕКТНЫЕ И УКАЗЫВАЮТ НА РЕАЛЬНУЮ НИШУ, ГДЕ УКАЗАНЫ ВСЕ РЕАЛЬНО И ПРАВИЛЬНО, УЧИТЫВАЯ ВСЕ ТОНКОСТИ {{NICHE}}. + + ЭТАЛОННАЯ СТРУКТУРА JSON (Следуй ей неукоснительно): + {{JSON_STRUCTURE}} + + НО ТЫ ДОЛЖЕН УЧИТЫВАТЬ, ЧТО ТЫ НЕ ПРОСТО ДОЛЖЕН ПРИДУМЫВАТЬ ДАНННЫЕ, ТЫ ДОЛЖЕН КОНКРЕТНЫЕ ДАННЫЕ ОТПРАВЛЯТЬ, КОТОРЫЕ РЕАЛЬНО ПОКАЗЫВАЮТ НАСКОЛЬКО ОНИ РЕАЛЬНЫЕ, И ОТОБРАЖАЮТ РЕАЛЬНОСТЬ, ССЫЛАЯСЬ НА НАСТОЯЩИЕ ИСТОЧНИКИ, И СДЕЛАВ ОЧЕНЬ ГЛУБОКИЙ АНАЛИЗ + """; return template .replace("{{NICHE}}", request.getBusinessNiche()) @@ -788,7 +807,7 @@ public class MarketingAnalysisService { } private List resolveCompetitorCompaniesForBenchmarks(MarketingAnalysisRequest request, - Map researchPack) { + Map researchPack) { int max = Math.max(5, Math.min(5, 15)); LinkedHashSet names = new LinkedHashSet<>(); @@ -819,7 +838,8 @@ public class MarketingAnalysisService { try { SerperSearchResult ratingResult = serperSearchService.search(qRating); names.addAll(extractCompanyNamesFromSerper(ratingResult, max - names.size())); - } catch (Exception ignored) {} + } catch (Exception ignored) { + } } if (names.size() < 4) { @@ -827,15 +847,16 @@ public class MarketingAnalysisService { try { SerperSearchResult mapsResult = serperSearchService.search(qMaps); names.addAll(extractCompanyNamesFromSerper(mapsResult, max - names.size())); - } catch (Exception ignored) {} + } catch (Exception ignored) { + } } - - names.removeIf(n -> { String lower = n.toLowerCase().trim(); - if (lower.length() < 2 || lower.length() > 50) return true; - if (lower.contains("обзор") || lower.contains("отзывы") || lower.contains("как выбрать")) return true; + if (lower.length() < 2 || lower.length() > 50) + return true; + if (lower.contains("обзор") || lower.contains("отзывы") || lower.contains("как выбрать")) + return true; return false; }); @@ -1022,7 +1043,7 @@ public class MarketingAnalysisService { } private MarketingAnalysisResponseV2 buildAnalysisV2ExampleForPrompt(MarketingAnalysisRequest request, - List competitorCompanies) { + List competitorCompanies) { MarketingAnalysisResponseV2 response = new MarketingAnalysisResponseV2(); String nicheTitle = (request.getBusinessNiche() != null) ? request.getBusinessNiche() : "[[НИША]]"; response.setReportTitle("Стратегический анализ рынка: " + nicheTitle); @@ -1043,10 +1064,18 @@ public class MarketingAnalysisService { marketOverview.setMetrics(moMetrics); MarketingAnalysisResponseV2.MonthlyOnlineIndex monthlyOnlineIndex = new MarketingAnalysisResponseV2.MonthlyOnlineIndex(); - monthlyOnlineIndex.setJanuary(0.1); monthlyOnlineIndex.setFebruary(0.1); monthlyOnlineIndex.setMarch(0.1); - monthlyOnlineIndex.setApril(0.1); monthlyOnlineIndex.setMay(0.1); monthlyOnlineIndex.setJune(0.1); - monthlyOnlineIndex.setJuly(0.1); monthlyOnlineIndex.setAugust(0.1); monthlyOnlineIndex.setSeptember(0.1); - monthlyOnlineIndex.setOctober(0.1); monthlyOnlineIndex.setNovember(0.1); monthlyOnlineIndex.setDecember(0.1); + monthlyOnlineIndex.setJanuary(0.1); + monthlyOnlineIndex.setFebruary(0.1); + monthlyOnlineIndex.setMarch(0.1); + monthlyOnlineIndex.setApril(0.1); + monthlyOnlineIndex.setMay(0.1); + monthlyOnlineIndex.setJune(0.1); + monthlyOnlineIndex.setJuly(0.1); + monthlyOnlineIndex.setAugust(0.1); + monthlyOnlineIndex.setSeptember(0.1); + monthlyOnlineIndex.setOctober(0.1); + monthlyOnlineIndex.setNovember(0.1); + monthlyOnlineIndex.setDecember(0.1); marketOverview.setMonthlyOnlineIndex(monthlyOnlineIndex); Map geography = new java.util.LinkedHashMap<>(); @@ -1068,18 +1097,21 @@ public class MarketingAnalysisService { targetAudience.setSegments(java.util.List.of( new MarketingAnalysisResponseV2.AudienceSegment("[[НАЗВАНИЕ_СЕГМЕНТА_1]]", "[[ОПИСАНИЕ_И_ДОЛЯ]]"), - new MarketingAnalysisResponseV2.AudienceSegment("[[НАЗВАНИЕ_СЕГМЕНТА_2]]", "[[ОПИСАНИЕ_И_ДОЛЯ]]") - )); + new MarketingAnalysisResponseV2.AudienceSegment("[[НАЗВАНИЕ_СЕГМЕНТА_2]]", "[[ОПИСАНИЕ_И_ДОЛЯ]]"))); java.util.Map ageStructure = new java.util.LinkedHashMap<>(); - ageStructure.put("18-24", "[[%]]"); ageStructure.put("25-34", "[[%]]"); - ageStructure.put("35-44", "[[%]]"); ageStructure.put("45-54", "[[%]]"); + ageStructure.put("18-24", "[[%]]"); + ageStructure.put("25-34", "[[%]]"); + ageStructure.put("35-44", "[[%]]"); + ageStructure.put("45-54", "[[%]]"); ageStructure.put("55+", "[[%]]"); targetAudience.setAgeStructure(ageStructure); java.util.Map incomeByAge = new java.util.LinkedHashMap<>(); - incomeByAge.put("18-24", 0); incomeByAge.put("25-34", 0); - incomeByAge.put("35-44", 0); incomeByAge.put("45-54", 0); + incomeByAge.put("18-24", 0); + incomeByAge.put("25-34", 0); + incomeByAge.put("35-44", 0); + incomeByAge.put("45-54", 0); incomeByAge.put("55+", 0); targetAudience.setIncomeByAge(incomeByAge); @@ -1187,8 +1219,9 @@ public class MarketingAnalysisService { java.util.Map channelEfficiency = new java.util.LinkedHashMap<>(); channelEfficiency.put("instagram", new MarketingAnalysisResponseV2.ChannelEfficiency("[[СТОИМОСТЬ]]", "High")); - channelEfficiency.put("tiktok", new MarketingAnalysisResponseV2.ChannelEfficiency("[[СТОИМОСТЬ]]", "High")); - channelEfficiency.put("kaspi_marketing", new MarketingAnalysisResponseV2.ChannelEfficiency("[[СТОИМОСТЬ]]", "High")); + channelEfficiency.put("tiktok", new MarketingAnalysisResponseV2.ChannelEfficiency("[[СТОИМОСТЬ]]", "High")); + channelEfficiency.put("kaspi_marketing", + new MarketingAnalysisResponseV2.ChannelEfficiency("[[СТОИМОСТЬ]]", "High")); channelEfficiency.put("google_ads", new MarketingAnalysisResponseV2.ChannelEfficiency("[[СТОИМОСТЬ]]", "High")); acquisitionChannels.setChannelEfficiency(channelEfficiency); acquisitionChannels.setConclusion("[[ВЫВОД_ПО_КАНАЛАМ]]"); @@ -1206,11 +1239,16 @@ public class MarketingAnalysisService { MarketingAnalysisResponseV2.FinancialPerformance financialPerformance = new MarketingAnalysisResponseV2.FinancialPerformance(); // ВАЖНО: Мы показываем, что month и year - это String поля для значений - financialPerformance.setMarketingExpenses(new MarketingAnalysisResponseV2.MonthYearValue("[[РАСХОД_МЕСЯЦ(всегда ставь 100 000, но нужно выводить информацию отталкиваясь от этой суммы, и ты должен выдавать только сумму]]", "[[РАСХОД_ГОД]]")); - financialPerformance.setClients(new MarketingAnalysisResponseV2.MonthYearValue("[[КЛИЕНТОВ_МЕСЯЦ]]", "[[КЛИЕНТОВ_ГОД]]")); + financialPerformance.setMarketingExpenses(new MarketingAnalysisResponseV2.MonthYearValue( + "[[РАСХОД_МЕСЯЦ(всегда ставь 100 000, но нужно выводить информацию отталкиваясь от этой суммы, и ты должен выдавать только сумму]]", + "[[РАСХОД_ГОД]]")); + financialPerformance + .setClients(new MarketingAnalysisResponseV2.MonthYearValue("[[КЛИЕНТОВ_МЕСЯЦ]]", "[[КЛИЕНТОВ_ГОД]]")); financialPerformance.setCac(new MarketingAnalysisResponseV2.MonthYearValue("[[CAC_МЕСЯЦ]]", "[[CAC_ГОД]]")); - financialPerformance.setRevenueFromClient(new MarketingAnalysisResponseV2.MonthYearValue("[[ВЫРУЧКА_МЕСЯЦ]]", "[[ВЫРУЧКА_ГОД]]")); - financialPerformance.setProfitFromClient(new MarketingAnalysisResponseV2.MonthYearValue("[[ПРИБЫЛЬ_МЕСЯЦ]]", "[[ПРИБЫЛЬ_ГОД]]")); + financialPerformance.setRevenueFromClient( + new MarketingAnalysisResponseV2.MonthYearValue("[[ВЫРУЧКА_МЕСЯЦ]]", "[[ВЫРУЧКА_ГОД]]")); + financialPerformance.setProfitFromClient( + new MarketingAnalysisResponseV2.MonthYearValue("[[ПРИБЫЛЬ_МЕСЯЦ]]", "[[ПРИБЫЛЬ_ГОД]]")); marketingEconomics.setFinancialPerformance(financialPerformance); marketingEconomics.setConclusion("[[ФИНАНСОВЫЙ_ВЫВОД]]"); @@ -1241,8 +1279,7 @@ public class MarketingAnalysisService { new MarketingAnalysisResponseV2.CorePillar("Быстрый старт", "[[ДЕЙСТВИЕ_1]]"), new MarketingAnalysisResponseV2.CorePillar("Оптимизация", "[[ДЕЙСТВИЕ_2]]"), new MarketingAnalysisResponseV2.CorePillar("Масштабирование", "[[ДЕЙСТВИЕ_3]]"), - new MarketingAnalysisResponseV2.CorePillar("Удержание", "[[ДЕЙСТВИЕ_4]]") - )); + new MarketingAnalysisResponseV2.CorePillar("Удержание", "[[ДЕЙСТВИЕ_4]]"))); recommendations.setConclusion("[[ФИНАЛЬНЫЙ_СОВЕТ]]"); sections.setRecommendations(recommendations); @@ -1389,7 +1426,6 @@ public class MarketingAnalysisService { } } - private Map generateJsonAnalysis(MarketingAnalysisRequest request) { Map result = new HashMap<>(); @@ -2061,7 +2097,6 @@ public class MarketingAnalysisService { return new HashMap<>(); } - private Map getStrategyAndFunnel(MarketingAnalysisRequest request) { try { String context = buildContextString(request); @@ -2529,6 +2564,7 @@ public class MarketingAnalysisService { cleaned = cleaned.replaceAll("(\\r?\\n){3,}", "\n\n").trim(); return cleaned; } + private String removeDanglingChartNarration(String text) { if (text == null || text.trim().isEmpty()) { return text; @@ -5405,7 +5441,8 @@ public class MarketingAnalysisService { public List getUserAnalyses(String userId) { logger.debug("Fetching analyses for userId: {}", userId); try { - // 1. Получаем стандартные анализы (V1 и те, что были созданы через startAnalysis) + // 1. Получаем стандартные анализы (V1 и те, что были созданы через + // startAnalysis) List v1Analyses = repository.findByUserIdOrderByCreatedAtDesc(userId); List v2Analyses = v2Repository.findByUserId(userId); @@ -5427,7 +5464,8 @@ public class MarketingAnalysisService { ma.setDetailLevel(v2.getDetailLevel()); ma.setAnalysisType(v2.getAnalysisType()); - // V2 анализы, сохраненные через generateAndSaveAnalysisV2, считаются завершенными + // V2 анализы, сохраненные через generateAndSaveAnalysisV2, считаются + // завершенными ma.setStatus("completed"); // Важно: проставляем ссылку на самого себя или помечаем, что это V2 @@ -5449,7 +5487,8 @@ public class MarketingAnalysisService { List allAnalyses = new ArrayList<>(v1Analyses); allAnalyses.addAll(v2AsV1List); - allAnalyses.sort(Comparator.comparing(MarketingAnalysis::getCreatedAt, Comparator.nullsLast(Comparator.reverseOrder()))); + allAnalyses.sort(Comparator.comparing(MarketingAnalysis::getCreatedAt, + Comparator.nullsLast(Comparator.reverseOrder()))); logger.debug("Found {} total analyses (V1: {}, V2: {}) for userId: {}", allAnalyses.size(), v1Analyses.size(), v2AsV1List.size(), userId); diff --git a/src/main/java/kz/konturai/parser/service/MarketingAnalysisV3Service.java b/src/main/java/kz/konturai/parser/service/MarketingAnalysisV3Service.java index 4f54669..33987c1 100644 --- a/src/main/java/kz/konturai/parser/service/MarketingAnalysisV3Service.java +++ b/src/main/java/kz/konturai/parser/service/MarketingAnalysisV3Service.java @@ -111,13 +111,15 @@ public class MarketingAnalysisV3Service { // Простые конкретные запросы — Serper работает надёжнее без OR-site-нагромождений Map queries = new LinkedHashMap<>(); - queries.put("market_overview", topic + " рынок Казахстан 2024 2025 статистика объём"); - queries.put("top_players", topic + " " + geo + " компании бренды Instagram лучшие"); - queries.put("google_maps", topic + " " + geo + " рейтинг отзывы 2GIS Google Maps"); - queries.put("prices", topic + " цены прайс " + geo + " 2024 2025"); - queries.put("pain_points", topic + " отзывы проблемы клиенты жалобы " + geo); - queries.put("smm_cases", niche + " SMM Instagram продвижение кейс Казахстан"); - queries.put("search_demand", topic + " частота запросов Казахстан сезонность"); + queries.put("market_overview", topic + " рынок Казахстан 2024 2025 статистика тренды"); + queries.put("instagram_players", topic + " " + geo + " Instagram аккаунты подписчики"); + queries.put("tiktok_players", topic + " " + geo + " TikTok популярные блогеры"); + queries.put("google_maps", topic + " " + geo + " рейтинг отзывы Google Maps 2GIS лучшие"); + queries.put("prices", topic + " цены прайс Казахстан 2024 2025"); + queries.put("pain_points", topic + " отзывы жалобы клиенты проблемы " + geo); + queries.put("smm_cases", niche + " SMM Instagram TikTok кейс Казахстан результаты"); + queries.put("search_demand", topic + " Яндекс WordStat Казахстан частота сезонность"); + queries.put("competitor_content", topic + " контент стратегия конкуренты что публикуют"); Map results = Collections.synchronizedMap(new LinkedHashMap<>()); @@ -196,14 +198,16 @@ public class MarketingAnalysisV3Service { private String sectionLabel(String key) { return switch (key) { - case "market_overview" -> "ОБЗОР РЫНКА"; - case "top_players" -> "ТОПОВЫЕ ИГРОКИ / КОНКУРЕНТЫ"; - case "google_maps" -> "РЕЙТИНГИ И ОТЗЫВЫ (Google Maps / 2GIS)"; - case "prices" -> "ЦЕНЫ"; - case "pain_points" -> "БОЛИ КЛИЕНТОВ / ОТЗЫВЫ"; - case "smm_cases" -> "SMM КЕЙСЫ"; - case "search_demand" -> "ПОИСКОВЫЙ СПРОС"; - default -> key.toUpperCase(); + case "market_overview" -> "ОБЗОР РЫНКА"; + case "instagram_players" -> "ИГРОКИ В INSTAGRAM"; + case "tiktok_players" -> "ИГРОКИ В TIKTOK"; + case "google_maps" -> "РЕЙТИНГИ И ОТЗЫВЫ (Google Maps / 2GIS)"; + case "prices" -> "ЦЕНЫ"; + case "pain_points" -> "БОЛИ КЛИЕНТОВ / ОТЗЫВЫ"; + case "smm_cases" -> "SMM КЕЙСЫ"; + case "search_demand" -> "ПОИСКОВЫЙ СПРОС"; + case "competitor_content" -> "КОНТЕНТ КОНКУРЕНТОВ"; + default -> key.toUpperCase(); }; } @@ -217,33 +221,60 @@ public class MarketingAnalysisV3Service { String cities = String.join(", ", getTargetCities(request)); return """ - Ты — старший маркетинговый аналитик рынка Казахстана, специализация: "%s". + Ты — старший маркетинговый аналитик рынка Казахстана и СНГ с 15-летним опытом. + Твоя специализация: конкурентный анализ, SMM-стратегия, потребительское поведение. - ТВОЯ ЦЕЛЬ: Сгенерировать строго валидный JSON-отчёт маркетингового анализа на основе предоставленных данных. + КОНТЕКСТ ПРОДУКТА: + Этот анализ создаётся для платформы, которая АВТОМАТИЧЕСКИ заменяет SMM-специалиста для малого и среднего бизнеса Казахстана. + Пользователи — обычные предприниматели, которые НЕ разбираются в маркетинге. Они хотят: + 1. Понять, что происходит на рынке прямо сейчас + 2. Узнать, кто их конкуренты и чем они сильны + 3. Получить чёткий план действий в соцсетях + 4. Видеть конкретные цифры, а не общие слова - ИСТОЧНИКИ (приоритет по убыванию): - 1. Конкретные факты и названия из блока "ДАННЫЕ ИЗ ПОИСКА" в запросе пользователя - 2. Твои знания о рынке Казахстана в нише "%s", городах: %s - 3. Метод Ферми — только для полей, по которым нет других данных + ТВОЯ РОЛЬ В ЭТОЙ СИСТЕМЕ: + Ты генерируешь структурированный JSON-отчёт, который фронтенд превращает в красивый дашборд с графиками, таблицами и визуализациями. От качества твоего JSON зависит, насколько ценным окажется продукт для клиента. - ОГРАНИЧИТЕЛИ РЕАЛИЗМА (НАРУШАТЬ ЗАПРЕЩЕНО — это физические ограничения): - - ER (Engagement Rate) в Instagram у бизнеса в KZ: 1.0%% – 12.0%%. Выше 15%% — невозможно. - - Рейтинг (rating): шкала от 1.0 до 5.0 включительно. Типичный бизнес: 4.0–4.8. - - Подписчики малого бизнеса KZ: 500–30,000. Среднего: 10,000–200,000. - - Постов в месяц: 4–25. - - avgMonthlySearches: узкая ниша 500–5,000/мес, популярная 10,000–80,000/мес. - - ciiIndex: строго от 0 до 100 (целое число). - - densityIndex: строго от 0.0 до 100.0. - - starDistribution: сумма 5 значений должна быть ровно 100.0. - - Radar-метрики (activity, engagement, video, reputation, frequency): строго от 0 до 100 (целые числа). + ═══════════════════════════════════════════════════ + ПРИНЦИПЫ РАБОТЫ (НАРУШАТЬ ЗАПРЕЩЕНО) + ═══════════════════════════════════════════════════ - ПРАВИЛА КАЧЕСТВА: - 1. В 3_competitor_map — только реальные бренды из данных поиска. Если поиск не дал имён — придумай правдоподобные казахстанские названия (НЕ "Компания А", НЕ "Бизнес 1"). - 2. Каждый город из запроса ОБЯЗАН присутствовать в cityDistribution и cityComparison. - 3. NULL в любом поле — ЗАПРЕЩЁН. Пустой список [] или 0 — допустимо. - 4. Все перечисляемые текстовые поля (businessStage, demandTrend, competitionLevel, intensityLabel) — строго на русском языке. - 5. keyFigures, contributingFactors, commonCta, relatedQueries, strategicFocus — конкретные, с цифрами где возможно. - 6. Формат вывода: только JSON объект. Никакого markdown, никаких пояснений вне JSON. + [П1] ПРИОРИТЕТ ИСТОЧНИКОВ: + 1. Конкретные факты из блока "ДАННЫЕ ИЗ ПОИСКА" (названия, цифры, ссылки) + 2. Твои знания о рынке Казахстана в данной нише + 3. Метод Ферми — только если нет данных из п.1 и п.2 + + [П2] РЕАЛИЗМ (физические ограничения рынка KZ): + • ER Instagram малый бизнес KZ: 1.5%% – 8.0%% (типично: 2-5%%) + • ER TikTok: 3.0%% – 15.0%% + • Рейтинг: шкала 1.0–5.0, типичный бизнес: 4.1–4.7 + • Подписчики малый бизнес: 500–15,000 | средний: 15,000–100,000 + • Посты/месяц: 6–20 (типично: 8–14) + • avgMonthlySearches узкая ниша: 300–5,000 | широкая: 8,000–80,000 + • ciiIndex: целое число 0–100 СТРОГО + • densityIndex: дробное 0.0–100.0 СТРОГО + • starDistribution: сумма пяти значений = ровно 100.0 + • radar-метрики: целые числа 0–100 СТРОГО + • Количество конкурентов по платформам: реалистичные числа, не 0 + + [П3] КОНКРЕТНОСТЬ: + • Имена конкурентов — ТОЛЬКО реальные казахстанские бренды из поиска + • Если поиск не вернул имён — придумай ПРАВДОПОДОБНЫЕ казахстанские названия + • ЗАПРЕЩЕНО: "Компания А", "Бизнес 1", "Конкурент X" + • ОБЯЗАТЕЛЬНО: каждый конкурент должен иметь реалистичный профиль Instagram/TikTok + + [П4] ПОЛНОТА: + • NULL — ЗАПРЕЩЁН в любом поле + • Пустой массив [] или число 0 — допустимы только если это реальное значение + • Все текстовые поля — на русском языке + • keyFigures, contributingFactors, commonCta, relatedQueries — конкретные, с цифрами + + [П5] ПОНЯТНОСТЬ ДЛЯ НЕ-МАРКЕТОЛОГА: + • 10_structured_conclusions — пишутся языком бизнесмена, не маркетолога + • 11_smm_strategy_rationale — объясняй КАК БУДТО объясняешь клиенту лично, просто и с цифрами + • Каждый вывод должен отвечать на вопрос "что это значит для моего бизнеса?" + + ФОРМАТ ОТВЕТА: строго JSON объект. Никакого текста вне JSON. Никаких markdown-блоков. """.formatted(niche, niche, cities); } @@ -292,78 +323,187 @@ public class MarketingAnalysisV3Service { .collect(Collectors.joining(",\n ", "[\n ", "\n ]")); return """ - ЗАДАНИЕ: Сгенерируй маркетинговый анализ v4.0 для рынка Казахстана. - - === ПРОФИЛЬ КЛИЕНТА === - Ниша: %s - Продукт / бренд: %s - Цель: %s - Описание продукта: %s - - Этап бизнеса: %s - Целевая аудитория: %s - Тип предложения: %s - Средний чек: %s - Поведение клиентов: %s - - Охват: %s - Основной город: %s - Города присутствия: %s - Города продвижения: %s - - Частота покупки: %s - Визуальный фактор: %s + ЗАДАНИЕ: Сгенерируй полный маркетинговый анализ v4.0 для рынка Казахстана. + + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + ПРОФИЛЬ БИЗНЕСА КЛИЕНТА + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + Ниша: %s + Продукт/Бренд: %s + Цель анализа: %s + Описание продукта: %s + Этап бизнеса: %s + Целевая аудитория: %s + Тип предложения: %s + Средний чек: %s + Поведение клиентов: %s + + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + ГЕОГРАФИЯ + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + Охват: %s + Основной город: %s + Города присутствия: %s + Города продвижения: %s + + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + ПОВЕДЕНИЕ И ЦЕННОСТИ КЛИЕНТОВ + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + Частота покупки: %s + Визуальный фактор: %s Приоритеты при выборе: %s - Как находят продукт: %s - Ценовая реакция: %s - - SMM статус: %s - Объём лидов: %s - Обработка обращений: %s - Ограничения бизнеса: %s - - Ссылки на соцсети клиента: %s - Известные конкуренты: %s - + Как находят продукт: %s + Ценовая реакция: %s + + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + ТЕКУЩЕЕ СОСТОЯНИЕ БИЗНЕСА + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + SMM статус: %s + Объём лидов: %s + Обработка обращений: %s + Ограничения: %s + Соцсети клиента: %s + Известные конкуренты: %s + + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ %s - - === ТРЕБОВАНИЯ К КОНКРЕТНЫМ БЛОКАМ === - + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + СПЕЦИАЛЬНЫЕ ТРЕБОВАНИЯ К БЛОКАМ + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + [0_executive_summary] + • businessStage — опиши кратко этап: "Старт", "Рост", "Масштабирование", "Зрелость" + • keyFigures — ровно 6 фактов с цифрами, каждый начинается с эмодзи-маркера: + "📊 N активных конкурентов в нише по Instagram в {город}" + "⭐ Средний рейтинг ниши на картах: X.X из 5.0" + "📈 Тренд спроса: {направление} (+ X%% год к году)" + "🎯 Средний ER в нише: X.X%%" + "📅 Лидеры публикуют N постов/мес" + "💬 N%% бизнесов в нише имеют >50 отзывов" + + [1_market_landscape] + • activePlayersByPlatform — укажи Instagram, TikTok, Telegram, GoogleMaps, TwoGis, YouTube + • cityDistribution — ОБЯЗАТЕЛЬНО все города из promotionCities: %s + • nicheReputationLevel — среднее доверие к нише: 0.0–5.0 + + [2_geo_structure] + • cityComparison — ОБЯЗАТЕЛЬНО все города: %s + • densityIndex — индекс конкурентной плотности 0–100: + 0–30 = мало игроков, легко занять нишу + 31–60 = средняя конкуренция + 61–85 = высокая конкуренция + 86–100 = рынок перегрет + [3_competitor_map] - - Минимум 5 конкурентов, максимум 10. - - Используй РЕАЛЬНЫЕ названия из блока "ТОПОВЫЕ ИГРОКИ" выше. - - Если известные конкуренты указаны клиентом — включи их обязательно. - - strengths и weaknesses: минимум 2 конкретных пункта каждый. - - [1_market_landscape → cityDistribution] — ИСПОЛЬЗУЙ ЭТОТ ШАБЛОН, замени 0 на реальные числа: - %s - - [2_geo_structure → cityComparison] — ИСПОЛЬЗУЙ ЭТОТ ШАБЛОН, замени 0 на реальные числа: - %s - - [9_user_positioning] — %s - - [10_structured_conclusions] — минимум 5 фактов с цифрами, НЕ советы. - Пример: "Средний ER в нише составляет X%%, что на Y%% выше среднего по KZ" - - [11_smm_strategy_rationale] — минимум 80 слов. - Учти профиль клиента: тип предложения "%s", аудитория "%s", визуальный фактор "%s". - Объясни почему именно SMM подходит для этого бизнеса, опираясь на цифры анализа. - - === JSON СТРУКТУРА (ЗАПОЛНИ ВСЕ ПОЛЯ РЕАЛЬНЫМИ ДАННЫМИ) === + • Минимум 7, максимум 10 конкурентов + • Используй РЕАЛЬНЫЕ названия из поиска — это критично + • Клиентские конкуренты из knownCompetitorLinks — включить ОБЯЗАТЕЛЬНО + • strengths: 3 конкретных сильных стороны (что они делают хорошо в SMM?) + • weaknesses: 3 конкретных слабости (что они делают плохо или не делают?) + • Пример слабости: "Нет видео-контента", "Не отвечают на комментарии", "Посты раз в 2 недели" + • Пример силы: "Reels каждые 3 дня с >5K просмотров", "Цены в каждом посте", "Stories каждый день" + • contentStyle — добавь дополнительное строковое поле: стиль контента конкурента + • lastActiveDate — строка: примерная дата последней активности "2024-01" или "активен" + + [4_content_profile] + • Проценты ДОЛЖНЫ в сумме давать 100.0: + demoContentPercent + expertContentPercent + salesContentPercent + + reviewsContentPercent + engagementContentPercent = 100.0 + • videoShare — доля видео от всего контента (Reels, TikTok, Stories) + • avgTextLengthCharacters — типичная длина подписи к посту в символах + • ctaUsagePercent — %% постов с явным призывом к действию + • topHashtags — добавь массив: 5 наиболее используемых хэштегов в нише + • bestPostingTime — добавь строку: лучшее время для публикации "18:00–21:00" + + [5_competition_intensity] + • ciiIndex — вычисляется по формуле: + (кол-во игроков × 0.3) + (ср. ER × 5) + (ср. частота × 2) + (активность отзывов × 0.2) + Нормируй к шкале 0–100 + • intensityLabel — одно из: "Низкая", "Ниже среднего", "Средняя", "Выше среднего", "Высокая", "Очень высокая" + • contributingFactors — 4 фактора с цифрами: + "Плотность: N активных аккаунтов в нише" + "Активность: средняя частота N постов/мес" + "Вовлечённость: средний ER X.X%%" + "Репутация: N%% бизнесов имеют рейтинг выше 4.5" + + [6_reputation_analysis] + • starDistribution — сумма РОВНО 100.0 + • highTrustBusinessShare — %% бизнесов с >100 отзывами И рейтингом >4.3 + • avgOwnerResponseTimeHours — среднее время ответа владельца в часах (0 = не отвечает) + • topComplaintThemes — добавь массив строк: топ-3 жалобы клиентов в нише + • topPraiseThemes — добавь массив строк: топ-3 похвалы клиентов в нише + + [7_behavioral_pattern] + • promoUsagePercent — %% постов конкурентов с акциями/скидками + • directBookingPercent — %% постов с призывом "записаться/заказать" + • priceVisibilityPercent — %% бизнесов, которые указывают цены открыто + • avgCycleDays — средний интервал между публикациями в днях + • commonCta — 5 реальных CTA-фраз из контента ниши (примеры из рынка KZ): + "Записывайтесь в Direct", "Цена в актуальном", "Звоните: +7..." + + [8_search_demand] + • seasonality — 12 месяцев, значения нормированы к 100 (100 = пиковый месяц) + • peakPeriods — 2-3 конкретных периода: "Март–Апрель (pre-Наурыз)", "Ноябрь (Black Friday)" + • relatedQueries — 6 реальных поисковых запросов на русском, которые используют клиенты ниши + • trendDirection — добавь строку: "Растущий", "Стабильный", "Падающий" + • yoyGrowthPercent — добавь число: изменение спроса год-к-году в %% + + [9_user_positioning] + • %s + • marketMetrics — реальные рыночные средние по нише (0–100): + activity: %% бизнесов, активно ведущих соцсети + engagement: нормированный средний ER (50 = средний ER ниши) + video: %% контента в формате видео + reputation: средний рейтинг нормированный (4.5 рейтинг = 90 из 100) + frequency: нормированная частота публикаций + • userMetrics — позиция клиента (0 если нет соцсетей) + • gap — добавь объект: разница marketMetrics - userMetrics по каждому параметру + • strategicFocus — 4 конкретных приоритета для клиента, отсортированных по важности + + [10_structured_conclusions] + • РОВНО 7 выводов — с цифрами, языком для бизнесмена + • Формат каждого вывода: "[ТЕМА]: [ФАКТ с цифрой]. [ЧТО ЭТО ЗНАЧИТ для бизнеса]" + + [11_smm_strategy_rationale] + • Минимум 120 слов + • Структура: ПОЧЕМУ SMM → ПОЧЕМУ ИМЕННО СЕЙЧАС → ЧТО КОНКРЕТНО ДЕЛАТЬ + • Пиши от первого лица, как консультант объясняет клиенту + • Обязательно упомяни: тип предложения "%s", аудиторию "%s", визуальный фактор "%s" + • Заканчивай конкретным следующим шагом: "Первый шаг — ..." + + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + ПОЛНАЯ JSON СТРУКТУРА ДЛЯ ЗАПОЛНЕНИЯ + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + { "0_executive_summary": { "businessStage": "строка на русском", - "geography": "строка на русском", + "geography": "строка — перечисление городов", "activeCompetitors": 0, "competitionLevel": "низкий | средний | высокий | очень высокий", "averageNicheEr": 0.0, "averageRating": 0.0, "demandTrend": "растущий | стабильный | падающий", - "keyFigures": ["факт с цифрой", "факт с цифрой", "факт", "факт", "факт"] + "keyFigures": [ + "📊 N активных конкурентов в нише по Instagram", + "⭐ Средний рейтинг ниши: X.X из 5.0", + "📈 Тренд спроса: +X%% год к году", + "🎯 Средний ER в нише: X.X%%", + "📅 Лидеры публикуют N постов/мес", + "💬 N%% бизнесов имеют >50 отзывов" + ] }, "1_market_landscape": { - "activePlayersByPlatform": {"Instagram": 0, "TikTok": 0, "GoogleMaps": 0}, + "activePlayersByPlatform": { + "Instagram": 0, + "TikTok": 0, + "Telegram": 0, + "GoogleMaps": 0, + "TwoGis": 0, + "YouTube": 0 + }, "cityDistribution": %s, "nicheReputationLevel": 0.0 }, @@ -373,15 +513,18 @@ public class MarketingAnalysisV3Service { }, "3_competitor_map": [ { - "name": "Реальное название бренда", + "name": "Название бренда", "platform": "Instagram", + "profileUrl": "https://instagram.com/...", "followers": 0, "postsPerMonth": 0, "er": 0.0, "rating": 0.0, "reviews": 0, - "strengths": ["сильная сторона 1", "сильная сторона 2"], - "weaknesses": ["слабая сторона 1", "слабая сторона 2"] + "contentStyle": "описание стиля контента", + "lastActiveDate": "активен", + "strengths": ["сила 1 с цифрами", "сила 2 с цифрами", "сила 3"], + "weaknesses": ["слабость 1", "слабость 2", "слабость 3"] } ], "4_content_profile": { @@ -392,53 +535,112 @@ public class MarketingAnalysisV3Service { "engagementContentPercent": 0.0, "videoShare": 0.0, "avgTextLengthCharacters": 0, - "ctaUsagePercent": 0.0 + "ctaUsagePercent": 0.0, + "topHashtags": ["#хэштег1", "#хэштег2", "#хэштег3", "#хэштег4", "#хэштег5"], + "bestPostingTime": "18:00–21:00" }, "5_competition_intensity": { "ciiIndex": 0, "intensityLabel": "строка на русском", - "contributingFactors": ["фактор 1", "фактор 2", "фактор 3"] + "contributingFactors": [ + "Плотность: N активных аккаунтов", + "Активность: ср. N постов/мес", + "Вовлечённость: ср. ER X.X%%", + "Репутация: N%% с рейтингом >4.5" + ] }, "6_reputation_analysis": { "avgNicheRating": 0.0, "medianReviews": 0, - "starDistribution": {"5": 0.0, "4": 0.0, "3": 0.0, "2": 0.0, "1": 0.0}, + "starDistribution": { + "5": 0.0, "4": 0.0, "3": 0.0, "2": 0.0, "1": 0.0 + }, "highTrustBusinessShare": 0.0, - "avgOwnerResponseTimeHours": 0 + "avgOwnerResponseTimeHours": 0, + "topComplaintThemes": ["жалоба 1", "жалоба 2", "жалоба 3"], + "topPraiseThemes": ["похвала 1", "похвала 2", "похвала 3"] }, "7_behavioral_pattern": { "promoUsagePercent": 0.0, "directBookingPercent": 0.0, "priceVisibilityPercent": 0.0, "avgCycleDays": 0, - "commonCta": ["CTA формулировка 1", "CTA формулировка 2", "CTA формулировка 3"] + "commonCta": [ + "CTA фраза 1", + "CTA фраза 2", + "CTA фраза 3", + "CTA фраза 4", + "CTA фраза 5" + ] }, "8_search_demand": { "avgMonthlySearches": 0, + "trendDirection": "Растущий | Стабильный | Падающий", + "yoyGrowthPercent": 0.0, "seasonality": [ - {"period": "Январь", "value": 0.0}, {"period": "Февраль", "value": 0.0}, - {"period": "Март", "value": 0.0}, {"period": "Апрель", "value": 0.0}, - {"period": "Май", "value": 0.0}, {"period": "Июнь", "value": 0.0}, - {"period": "Июль", "value": 0.0}, {"period": "Август", "value": 0.0}, - {"period": "Сентябрь", "value": 0.0}, {"period": "Октябрь", "value": 0.0}, - {"period": "Ноябрь", "value": 0.0}, {"period": "Декабрь", "value": 0.0} + {"period": "Январь", "value": 0.0}, + {"period": "Февраль", "value": 0.0}, + {"period": "Март", "value": 0.0}, + {"period": "Апрель", "value": 0.0}, + {"period": "Май", "value": 0.0}, + {"period": "Июнь", "value": 0.0}, + {"period": "Июль", "value": 0.0}, + {"period": "Август", "value": 0.0}, + {"period": "Сентябрь", "value": 0.0}, + {"period": "Октябрь", "value": 0.0}, + {"period": "Ноябрь", "value": 0.0}, + {"period": "Декабрь", "value": 0.0} ], - "peakPeriods": ["период 1", "период 2"], - "relatedQueries": ["запрос 1", "запрос 2", "запрос 3"] + "peakPeriods": ["Период 1 с объяснением", "Период 2 с объяснением"], + "relatedQueries": [ + "запрос 1", "запрос 2", "запрос 3", + "запрос 4", "запрос 5", "запрос 6" + ] }, "9_user_positioning": { - "marketMetrics": {"activity": 0, "engagement": 0, "video": 0, "reputation": 0, "frequency": 0}, - "userMetrics": {"activity": 0, "engagement": 0, "video": 0, "reputation": 0, "frequency": 0}, - "mode": "BENCHMARK", - "strategicFocus": ["приоритет 1", "приоритет 2", "приоритет 3"] + "marketMetrics": { + "activity": 0, + "engagement": 0, + "video": 0, + "reputation": 0, + "frequency": 0 + }, + "userMetrics": { + "activity": 0, + "engagement": 0, + "video": 0, + "reputation": 0, + "frequency": 0 + }, + "gap": { + "activity": 0, + "engagement": 0, + "video": 0, + "reputation": 0, + "frequency": 0 + }, + "mode": "BENCHMARK | COMPARISON", + "strategicFocus": [ + "Приоритет 1: конкретное действие", + "Приоритет 2: конкретное действие", + "Приоритет 3: конкретное действие", + "Приоритет 4: конкретное действие" + ] }, - "10_structured_conclusions": ["вывод 1", "вывод 2", "вывод 3", "вывод 4", "вывод 5"], - "11_smm_strategy_rationale": "Минимум 80 слов с конкретными цифрами." + "10_structured_conclusions": [ + "КОНКУРЕНЦИЯ: факт. Что это значит.", + "КОНТЕНТ: факт. Что это значит.", + "РЕПУТАЦИЯ: факт. Что это значит.", + "СПРОС: факт. Что это значит.", + "ЦЕНЫ: факт. Что это значит.", + "ВИДЕО: факт. Что это значит.", + "ВОЗМОЖНОСТЬ: факт. Что это значит." + ], + "11_smm_strategy_rationale": "Минимум 120 слов. ПОЧЕМУ SMM → ПОЧЕМУ СЕЙЧАС → ЧТО ДЕЛАТЬ → ПЕРВЫЙ ШАГ." } - - ОТВЕТ: только JSON. Никакого текста вне JSON. + + ОТВЕТ: только JSON. Никакого текста вне JSON. Никаких markdown-блоков ```json. """.formatted( - // Профиль клиента sanitize(request.getBusinessNiche()), sanitize(request.getProductName()), sanitize(request.getGoal()), @@ -463,17 +665,13 @@ public class MarketingAnalysisV3Service { constraintsDesc, String.join(", ", safeList(request.getUserSocialLinks())), String.join(", ", safeList(request.getKnownCompetitorLinks())), - // Evidence block evidence, - // Требования к блокам cityDistJson, cityCompJson, positioningNote, - // Параметры для rationale offerTypeDesc, clientTargetDesc, visualFactorDesc, - // JSON шаблон городов cityDistJson, cityCompJson ); @@ -574,6 +772,28 @@ public class MarketingAnalysisV3Service { if (cp.getFollowers() < 0) cp.setFollowers(0); if (cp.getPostsPerMonth() < 0) cp.setPostsPerMonth(0); if (cp.getReviews() < 0) cp.setReviews(0); + if (cp.getProfileUrl() == null) cp.setProfileUrl(""); + if (cp.getContentStyle() == null) cp.setContentStyle(""); + if (cp.getLastActiveDate()== null) cp.setLastActiveDate(""); + } + } + + // Нормализация суммы контент-профиля к ровно 100.0 + if (r.getContentProfile() != null) { + var cp = r.getContentProfile(); + double total = cp.getDemoContentPercent() + cp.getExpertContentPercent() + + cp.getSalesContentPercent() + cp.getReviewsContentPercent() + + cp.getEngagementContentPercent(); + if (total > 0 && Math.abs(total - 100.0) > 1.0) { + double factor = 100.0 / total; + cp.setDemoContentPercent( round1(cp.getDemoContentPercent() * factor)); + cp.setExpertContentPercent( round1(cp.getExpertContentPercent() * factor)); + cp.setSalesContentPercent( round1(cp.getSalesContentPercent() * factor)); + cp.setReviewsContentPercent( round1(cp.getReviewsContentPercent() * factor)); + // Последний элемент добирает остаток до ровно 100 + double rest = 100.0 - cp.getDemoContentPercent() - cp.getExpertContentPercent() + - cp.getSalesContentPercent() - cp.getReviewsContentPercent(); + cp.setEngagementContentPercent(Math.max(0.0, round1(rest))); } } @@ -590,6 +810,8 @@ public class MarketingAnalysisV3Service { Math.round((v / fSum * 100.0) * 10.0) / 10.0); } } + if (ra.getTopComplaintThemes() == null) ra.setTopComplaintThemes(List.of()); + if (ra.getTopPraiseThemes() == null) ra.setTopPraiseThemes(List.of()); } if (r.getCompetitionIntensity() != null) { @@ -602,9 +824,22 @@ public class MarketingAnalysisV3Service { clamp(r.getGeoStructure().getDensityIndex(), 0.0, 100.0)); } + // Авто-вычисление gap и нормализация radar-метрик if (r.getUserPositioning() != null) { - fixRadar(r.getUserPositioning().getMarketMetrics()); - fixRadar(r.getUserPositioning().getUserMetrics()); + var up = r.getUserPositioning(); + fixRadar(up.getMarketMetrics()); + fixRadar(up.getUserMetrics()); + if (up.getGap() == null) up.setGap(new MarketingAnalysisV3Result.RadarMetrics()); + if (up.getMarketMetrics() != null && up.getUserMetrics() != null) { + var gap = up.getGap(); + var m = up.getMarketMetrics(); + var u = up.getUserMetrics(); + gap.setActivity( m.getActivity() - u.getActivity()); + gap.setEngagement(m.getEngagement() - u.getEngagement()); + gap.setVideo( m.getVideo() - u.getVideo()); + gap.setReputation(m.getReputation() - u.getReputation()); + gap.setFrequency( m.getFrequency() - u.getFrequency()); + } } log.info("fixConstraints passed — result is physically valid"); @@ -619,6 +854,10 @@ public class MarketingAnalysisV3Service { m.setFrequency( (int) clamp(m.getFrequency(), 0, 100)); } + private double round1(double v) { + return Math.round(v * 10.0) / 10.0; + } + private double clamp(double val, double min, double max) { return Math.max(min, Math.min(max, val)); } diff --git a/src/main/java/kz/konturai/parser/service/MarketingStrategyV3Service.java b/src/main/java/kz/konturai/parser/service/MarketingStrategyV3Service.java index 6b99661..f1f5036 100644 --- a/src/main/java/kz/konturai/parser/service/MarketingStrategyV3Service.java +++ b/src/main/java/kz/konturai/parser/service/MarketingStrategyV3Service.java @@ -53,7 +53,9 @@ public class MarketingStrategyV3Service { @Value("${image.generation.delayBetweenRequestsMs:5000}") private long delayBetweenRequestsMs; - + // ===================================================================== + // PUBLIC API + // ===================================================================== public Optional getStrategyById(String id) { return repository.findById(id); @@ -105,10 +107,10 @@ public class MarketingStrategyV3Service { addStatusHistory(strategy, "processing", "Запускаю скоринговую систему..."); repository.save(strategy); - // Step 1: Scoring — строго по документу + // Step 1: Scoring ScoringResult scoring = calculateScoringModel(analysis.getRequestData(), analysis); - log.info("[Strategy {}] Scoring complete → model={} (E:{} Au:{} T:{} C:{}) modifiers={}", + log.info("[Strategy {}] Scoring → model={} (E:{} Au:{} T:{} C:{}) modifiers={}", strategyId, scoring.getModel(), scoring.getEntryScore(), scoring.getAuthorityScore(), @@ -117,8 +119,10 @@ public class MarketingStrategyV3Service { .map(StrategyModifier::getTitle) .collect(Collectors.joining(", "))); - addStatusHistory(strategy, "processing", - "✅ " + scoring.getRationale()); + // ── Сразу сохраняем название модели в entity ────────────────────── + strategy.setScoringModelName(scoring.getModel().name()); + strategy.setScoringModelTitle(scoring.getModel().getTitle()); + addStatusHistory(strategy, "processing", "✅ " + scoring.getRationale()); repository.save(strategy); // Step 2: Build prompts @@ -140,15 +144,15 @@ public class MarketingStrategyV3Service { Map strategyData = objectMapper.readValue(jsonResponse, new TypeReference<>() {}); - // Сохраняем referenceFilenames из initialData + // Переносим referenceFilenames из initialData if (strategy.getStrategyData() != null) { strategy.getStrategyData().forEach(strategyData::putIfAbsent); } - // Добавляем скоринг-метаданные для фронта - strategyData.put("scoringModel", scoring.getModel().name()); - strategyData.put("scoringModelTitle", scoring.getModel().getTitle()); - strategyData.put("scoringRationale", scoring.getRationale()); + // Скоринг-метаданные для фронта (дублируем в strategyData) + 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) @@ -160,12 +164,12 @@ public class MarketingStrategyV3Service { "conversion", scoring.getConversionScore() )); - populateStrategyEntity(strategy, strategyData); + populateStrategyEntity(strategy, strategyData, scoring); repository.save(strategy); // Step 4: Media generation addStatusHistory(strategy, "processing", - "Создаю фото и видео креативы. Это займёт несколько минут..."); + "Создаю фото и видео креативы (Veo 3). Это займёт несколько минут..."); repository.save(strategy); generateMediaAssets(strategy, analysis); @@ -176,7 +180,7 @@ public class MarketingStrategyV3Service { "🎉 Стратегия готова! Модель: " + scoring.getModel().getTitle()); repository.save(strategy); - log.info("[Strategy {}] Completed. Model: {}, Posts: {}", + log.info("[Strategy {}] Completed. Model={}, Posts={}", strategyId, scoring.getModel(), strategy.getPostCalendar() != null ? strategy.getPostCalendar().size() : 0); @@ -187,14 +191,7 @@ public class MarketingStrategyV3Service { } // ===================================================================== - // SCORING ENGINE — точное соответствие Scoring Model документу - // - // Факторы и веса (раздел 3 документа): - // 1. Поведение клиента — вес 3 (наивысший) - // 2. Средний чек — вес 2 - // 3. Этап бизнеса — вес 2 - // 4. B2B / B2C — вес 1 - // 5. Рыночная интенсивность — вес 1 (из анализа) + // SCORING ENGINE // ===================================================================== public ScoringResult calculateScoringModel( @@ -204,239 +201,125 @@ public class MarketingStrategyV3Service { int entry = 0, authority = 0, trust = 0, conversion = 0; List rationaleFactors = new ArrayList<>(); - // ── A. Поведение клиента (вес 3) — таблица A документа ──────────── - // CustomerBehavior enum: BUY_QUICKLY, COMPARE_OPTIONS, REQUEST_PROPOSAL, - // CHECK_REVIEWS, NEED_CONSULTATION if (req.getCustomerBehaviors() != null) { for (var cb : req.getCustomerBehaviors()) { switch (cb) { - 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 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("клиенты приходят на консультацию"); } } } } - // ── B. Средний чек (вес 2) — таблица B документа ───────────────── - // AverageCheck enum: LOW, MEDIUM, HIGH if (req.getAverageCheck() != null) { switch (req.getAverageCheck()) { - case LOW -> { - conversion += 2; - rationaleFactors.add("низкий средний чек"); - } - case MEDIUM -> { - authority += 1; trust += 1; conversion += 1; - rationaleFactors.add("средний чек"); - } - case HIGH -> { - authority += 2; trust += 1; - rationaleFactors.add("высокий средний чек"); - } + case LOW -> { conversion += 2; rationaleFactors.add("низкий средний чек"); } + case MEDIUM -> { authority += 1; trust += 1; conversion += 1; rationaleFactors.add("средний чек"); } + case HIGH -> { authority += 2; trust += 1; rationaleFactors.add("высокий средний чек"); } } } - // ── C. Этап бизнеса (вес 2) — таблица C документа ─────────────── - // BusinessStage enum: JUST_STARTING, LESS_THAN_ONE_YEAR, - // ONE_TO_THREE_YEARS, MORE_THAN_THREE_YEARS 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 лет"); } } } - // ── D. B2B / B2C (вес 1) — таблица D документа ─────────────────── - // ClientTarget enum: PRIVATE_CLIENTS (B2C), BUSINESS (B2B), MIXED if (req.getClientTarget() != null) { switch (req.getClientTarget()) { - 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 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"); } } } - // ── E. Рыночная интенсивность (вес 1) — таблица E документа ────── - // Берём CII из результатов анализа если доступен 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 + ")"); + rationaleFactors.add("низкая конкуренция (CII=" + cii + ")"); } else if (cii < 65) { trust += 1; conversion += 1; - rationaleFactors.add("средняя конкуренция на рынке (CII=" + cii + ")"); + rationaleFactors.add("средняя конкуренция (CII=" + cii + ")"); } else { trust += 2; - rationaleFactors.add("высокая конкуренция на рынке (CII=" + cii + ")"); + rationaleFactors.add("высокая конкуренция (CII=" + cii + ")"); } } - log.debug("Scoring raw: Entry={} Authority={} Trust={} Conversion={}", entry, authority, trust, conversion); + log.debug("Scoring raw: E={} Au={} T={} C={}", entry, authority, trust, conversion); - // ── Выбор победителя (раздел 5 документа: ModelScore = Σ баллов) ── int maxScore = Math.max(Math.max(entry, authority), Math.max(trust, conversion)); - StrategyModel selectedModel = resolveModelWithTiebreaker( entry, authority, trust, conversion, maxScore, req, analysis); - - // ── Активируем модификаторы (раздел из документа, макс. 3) ─────── List modifiers = resolveModifiers(req, analysis); - - // ── Формируем объяснение для пользователя (раздел 9 документа) ─── String rationale = buildRationaleText(selectedModel, rationaleFactors, entry, authority, trust, conversion); - return new ScoringResult(selectedModel, - entry, authority, trust, conversion, + return new ScoringResult(selectedModel, entry, authority, trust, conversion, modifiers, rationale); } - /** - * Разрешает победителя при равенстве баллов. - * Правила из раздела 6 документа: - * 1. Authority = Trust при высоком чеке → Authority - * 2. Trust = Conversion при высокой конкуренции → Trust - * 3. Entry = что-то при stage=launch → Entry - */ private StrategyModel resolveModelWithTiebreaker( - int entry, int authority, int trust, int conversion, - int maxScore, + int entry, int authority, int trust, int conversion, int maxScore, kz.konturai.parser.dto.MarketingAnalysisV3Request req, MarketingAnalysisV3Document analysis) { boolean highCheck = req.getAverageCheck() != null && req.getAverageCheck() == kz.konturai.parser.enums.AverageCheck.HIGH; - boolean highCompetition = 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; - // Правило 1: Authority = Trust, чек высокий → Authority - if (authority == maxScore && trust == maxScore && highCheck) { - log.debug("Tiebreaker #1 applied: Authority wins over Trust (high check)"); - return StrategyModel.AUTHORITY; - } + 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; - // Правило 2: Trust = Conversion, высокая конкуренция → Trust - if (trust == maxScore && conversion == maxScore && highCompetition) { - log.debug("Tiebreaker #2 applied: Trust wins over Conversion (high competition)"); - return StrategyModel.TRUST; - } - - // Правило 3: Entry в равенстве при launch → Entry - if (entry == maxScore && isLaunch) { - log.debug("Tiebreaker #3 applied: Entry wins (stage=launch)"); - return StrategyModel.ENTRY; - } - - // Обычный выбор по максимуму - if (authority == maxScore) return StrategyModel.AUTHORITY; + if (authority == maxScore) return StrategyModel.AUTHORITY; if (conversion == maxScore) return StrategyModel.CONVERSION; if (trust == maxScore) return StrategyModel.TRUST; return StrategyModel.ENTRY; } - /** - * Определяет активные модификаторы. Порядок — по приоритету важности. - * Максимум 3 модификатора согласно документу. - */ private List resolveModifiers( kz.konturai.parser.dto.MarketingAnalysisV3Request req, MarketingAnalysisV3Document analysis) { List mods = new ArrayList<>(); - // 1. HIGH_COMPETITION — CII > 60 из анализа - if (mods.size() < 3 - && analysis != null && analysis.getResultData() != null + if (mods.size() < 3 && analysis != null && analysis.getResultData() != null && analysis.getResultData().getCompetitionIntensity() != null - && analysis.getResultData().getCompetitionIntensity().getCiiIndex() > 60) { + && analysis.getResultData().getCompetitionIntensity().getCiiIndex() > 60) mods.add(StrategyModifier.HIGH_COMPETITION); - } - // 2. HIGH_CHECK — средний чек HIGH - if (mods.size() < 3 - && req.getAverageCheck() != null - && req.getAverageCheck() == kz.konturai.parser.enums.AverageCheck.HIGH) { + if (mods.size() < 3 && req.getAverageCheck() != null + && req.getAverageCheck() == kz.konturai.parser.enums.AverageCheck.HIGH) mods.add(StrategyModifier.HIGH_CHECK); - } - // 3. SHORT_DECISION — клиенты покупают быстро - if (mods.size() < 3 - && req.getCustomerBehaviors() != null - && req.getCustomerBehaviors().contains( - kz.konturai.parser.enums.CustomerBehavior.BUY_QUICKLY)) { + if (mods.size() < 3 && req.getCustomerBehaviors() != null + && req.getCustomerBehaviors().contains(kz.konturai.parser.enums.CustomerBehavior.BUY_QUICKLY)) mods.add(StrategyModifier.SHORT_DECISION); - } - // 4. SOCIAL_PROOF_SENSITIVE — клиенты проверяют кейсы/отзывы - if (mods.size() < 3 - && req.getCustomerBehaviors() != null - && req.getCustomerBehaviors().contains( - kz.konturai.parser.enums.CustomerBehavior.CHECK_REVIEWS)) { + if (mods.size() < 3 && req.getCustomerBehaviors() != null + && req.getCustomerBehaviors().contains(kz.konturai.parser.enums.CustomerBehavior.CHECK_REVIEWS)) mods.add(StrategyModifier.SOCIAL_PROOF_SENSITIVE); - } - // 5. RETENTION — регулярные покупки или подписка - if (mods.size() < 3 - && req.getPurchaseFrequency() != null - && req.getPurchaseFrequency() == kz.konturai.parser.enums.PurchaseFrequency.REGULARLY) { + if (mods.size() < 3 && req.getPurchaseFrequency() != null + && req.getPurchaseFrequency() == kz.konturai.parser.enums.PurchaseFrequency.REGULARLY) mods.add(StrategyModifier.RETENTION); - } - // 6. MULTI_GEO — несколько городов продвижения if (mods.size() < 3) { - List cities = req.getPromotionCities() != null - ? req.getPromotionCities() + List cities = req.getPromotionCities() != null ? req.getPromotionCities() : (req.getPresenceCities() != null ? req.getPresenceCities() : List.of()); - if (cities.size() > 1) { - mods.add(StrategyModifier.MULTI_GEO); - } + if (cities.size() > 1) mods.add(StrategyModifier.MULTI_GEO); } return mods; @@ -444,13 +327,9 @@ public class MarketingStrategyV3Service { private String buildRationaleText(StrategyModel model, List factors, int entry, int authority, int trust, int conversion) { - String topFactors = factors.stream() - .limit(3) - .collect(Collectors.joining(", ")); - + String topFactors = factors.stream().limit(3).collect(Collectors.joining(", ")); return String.format( - "Выбрана модель %s (баллы: Entry=%d, Authority=%d, Trust=%d, Conversion=%d). " + - "Причины: %s.", + "Выбрана модель %s (баллы: Entry=%d, Authority=%d, Trust=%d, Conversion=%d). Причины: %s.", model.getTitle(), entry, authority, trust, conversion, topFactors.isEmpty() ? "анализ профиля бизнеса" : topFactors); } @@ -460,19 +339,16 @@ public class MarketingStrategyV3Service { // ===================================================================== private String buildSystemPrompt(ScoringResult scoring) { - String modifiersBlock; - if (scoring.getActiveModifiers().isEmpty()) { - modifiersBlock = "Активных модификаторов нет — применяй базовую модель без корректировок."; - } else { - modifiersBlock = "ПРИМЕНИТЬ СЛЕДУЮЩИЕ МОДИФИКАТОРЫ К БАЗОВОЙ МОДЕЛИ:\n\n" - + scoring.getActiveModifiers().stream() - .map(StrategyModifier::getInstructions) - .collect(Collectors.joining("\n")); - } + String modifiersBlock = scoring.getActiveModifiers().isEmpty() + ? "Активных модификаторов нет — применяй базовую модель без корректировок." + : "ПРИМЕНИТЬ СЛЕДУЮЩИЕ МОДИФИКАТОРЫ К БАЗОВОЙ МОДЕЛИ:\n\n" + + scoring.getActiveModifiers().stream() + .map(StrategyModifier::getInstructions) + .collect(Collectors.joining("\n")); - String contentRulesSafe = scoring.getModel().getContentRules().replace("%", "%%"); + String contentRulesSafe = scoring.getModel().getContentRules().replace("%", "%%"); String modifiersBlockSafe = modifiersBlock.replace("%", "%%"); - String rationaleSafe = scoring.getRationale().replace("%", "%%"); + String rationaleSafe = scoring.getRationale().replace("%", "%%"); return """ ТЫ — СТАРШИЙ SMM СТРАТЕГ РЫНКА КАЗАХСТАНА. @@ -491,34 +367,32 @@ public class MarketingStrategyV3Service { %s ════════════════════════════════════════════════ - КРИТИЧЕСКИЕ ПРАВИЛА ГЕНЕРАЦИИ КОНТЕНТА: + КРИТИЧЕСКИЕ ПРАВИЛА ДЛЯ КАЗАХСТАНСКОГО РЫНКА: 1. КОЛИЧЕСТВО ПОСТОВ: - 4 недели × минимум 3 поста = МИНИМУМ 12 постов в postCalendar - - Оптимально: 14-16 постов (равномерно распределить по неделям) + - Оптимально: 14–16 постов (равномерно по неделям) - 2. СТРОГОЕ СОБЛЮДЕНИЕ ПРОПОРЦИЙ: - Подсчитай итоговое количество постов и распредели типы контента - строго согласно процентам модели (расчёт в userPrompt). + 2. ФОРМАТЫ — ОГРАНИЧЕНИЕ НА ВИДЕО: + - contentType: ТОЛЬКО "фото" или "видео" + - МАКСИМУМ 2 ВИДЕО-ПОСТА НА ВЕСЬ ПЛАН. Остальные — "фото". + - Это техническое ограничение системы генерации видео. - 3. ФОРМАТЫ: - - contentType принимает ТОЛЬКО два значения: "фото" или "видео" - - Соблюдай %% видео/фото из правил модели + 3. ЯЗЫК И ЛОКАЛИЗАЦИЯ — СТРОГО: + - ВСЕ тексты постов (postText) — ТОЛЬКО на русском языке + - ВСЕ хэштеги — ТОЛЬКО на русском или казахском языке + - ЗАПРЕЩЕНО использовать английские хэштеги (#marketing, #beauty, #business и т.д.) + - ПРАВИЛЬНО: #маркетингказахстан #бьютикз #бизнесалматы #астана #алматы #казахстан + - Упоминать конкретные казахстанские города, реалии, культурный контекст - 4. CTA В ПОСТАХ: - Используй ТОЛЬКО CTA-фразы из стиля своей модели (указаны в правилах выше) + 4. CTA — ТОЛЬКО из стиля своей модели (указано в правилах выше) - 5. ТЕКСТЫ ПОСТОВ (postText): - - Минимум 120 слов в каждом посте — реальный живой текст - - Пиши на русском языке, для аудитории Казахстана - - Конкретика, факты, польза — без воды и шаблонных фраз + 5. ТЕКСТЫ ПОСТОВ: + - Минимум 120 слов, живой разговорный русский язык + - Конкретика для Казахстана: цены в тенге, названия районов, локальные праздники - Эмодзи уместно, умеренно - 6. ХЭШТЕГИ: - - 5-10 тематических хэштегов + 1-2 гео (#алматы / #астана / #казахстан) - - Чередовать популярные и нишевые - - 7. РАСПИСАНИЕ: + 6. РАСПИСАНИЕ: - Instagram: 09:00, 12:00, 18:00, 20:00 - TikTok: 18:00, 20:00, 22:00 - Telegram: 10:00, 13:00, 19:00 @@ -555,7 +429,7 @@ public class MarketingStrategyV3Service { Выбранная модель: %s Активные модификаторы: %s - ══ ОБЯЗАТЕЛЬНЫЙ ПЛАН ПОСТОВ (рассчитан алгоритмом) ══ + ══ ОБЯЗАТЕЛЬНЫЙ ПЛАН ПОСТОВ ══ %s ══ ТРЕБУЕМАЯ JSON СТРУКТУРА ══ @@ -564,12 +438,12 @@ public class MarketingStrategyV3Service { "scoringRationale": "%s", "recommendedDurationWeeks": 4, "recommendedPlatforms": ["Instagram", "TikTok"], - "rationale": "Обоснование выбора этих платформ для данного бизнеса (минимум 60 слов)", + "rationale": "Обоснование выбора платформ (минимум 60 слов)", "weeklyPlans": [ { "weekNumber": 1, - "mainThemes": ["Конкретная тема 1", "Конкретная тема 2", "Конкретная тема 3"], - "contentRecommendations": "Детальные рекомендации что снимать и писать на этой неделе (минимум 40 слов)", + "mainThemes": ["Конкретная тема 1", "Конкретная тема 2"], + "contentRecommendations": "Что снимать и писать на этой неделе (минимум 40 слов)", "priorityPlatforms": ["Instagram"] } ], @@ -578,15 +452,19 @@ public class MarketingStrategyV3Service { "publishDate": "2024-01-15T10:00:00", "platform": "Instagram", "contentType": "фото", - "theme": "Конкретная тема поста — не шаблон", - "postText": "Полный текст поста минимум 120 слов. Живой язык, конкретика для Казахстана. CTA в конце поста.", - "hashtags": ["#хэштег1", "#хэштег2", "#алматы"], + "theme": "Конкретная тема поста", + "postText": "Полный текст поста минимум 120 слов на русском языке. Конкретика для Казахстана. CTA в конце.", + "hashtags": ["#хэштегнарусском", "#алматы", "#казахстан"], "publishTime": "10:00" } ] } - НАПОМИНАНИЕ: postCalendar должен содержать МИНИМУМ 12 постов (4 недели × 3 поста). + СТРОГИЕ ТРЕБОВАНИЯ: + - postCalendar: МИНИМУМ 12 постов + - postText: ТОЛЬКО на русском языке + - hashtags: ТОЛЬКО на русском/казахском (#алматы, #астана, #казахстан, #бизнескз и т.д.) + - НОЛЬ английских слов в хэштегах ОТВЕТ: только JSON. Никакого текста вне JSON. """.formatted( clientJson, @@ -602,132 +480,92 @@ public class MarketingStrategyV3Service { ); } - /** - * Извлекает ключевые данные из результатов анализа для контекста AI. - * Чем больше конкретики — тем релевантнее контент-план. - */ private String buildAnalysisContext(MarketingAnalysisV3Document analysis) { - if (analysis == null || analysis.getResultData() == null) { + if (analysis == null || analysis.getResultData() == null) return "Данные анализа недоступны — ориентируйся на профиль бизнеса."; - } - var r = analysis.getResultData(); + 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("Средний ER: ").append(es.getAverageNicheEr()).append("%\n"); sb.append("Средний рейтинг: ").append(es.getAverageRating()).append("/5.0\n"); sb.append("Тренд спроса: ").append(es.getDemandTrend()).append("\n"); - if (es.getKeyFigures() != null && !es.getKeyFigures().isEmpty()) { - sb.append("Ключевые факты: ") - .append(String.join("; ", es.getKeyFigures())).append("\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 — ") + sb.append("CII: ").append(r.getCompetitionIntensity().getCiiIndex()).append("/100 — ") .append(r.getCompetitionIntensity().getIntensityLabel()).append("\n"); - if (r.getCompetitionIntensity().getContributingFactors() != null) { - sb.append("Факторы конкуренции: ") + if (r.getCompetitionIntensity().getContributingFactors() != null) + sb.append("Факторы: ") .append(String.join("; ", r.getCompetitionIntensity().getContributingFactors())).append("\n"); - } } - if (r.getContentProfile() != null) { - var cp = r.getContentProfile(); - sb.append("Доля видео у конкурентов: ").append(cp.getVideoShare()).append("%\n"); - sb.append("Средняя длина текста конкурентов: ").append(cp.getAvgTextLengthCharacters()).append(" симв.\n"); - sb.append("Используют CTA: ").append(cp.getCtaUsagePercent()).append("% конкурентов\n"); + sb.append("Доля видео у конкурентов: ").append(r.getContentProfile().getVideoShare()).append("%\n"); + sb.append("CTA используют: ").append(r.getContentProfile().getCtaUsagePercent()).append("%\n"); } - - if (r.getBehavioralPattern() != null) { - var bp = r.getBehavioralPattern(); - sb.append("Акции используют: ").append(bp.getPromoUsagePercent()).append("% конкурентов\n"); - if (bp.getCommonCta() != null && !bp.getCommonCta().isEmpty()) { - sb.append("Популярные CTA у конкурентов: ") - .append(String.join(", ", bp.getCommonCta())).append("\n"); - } - } - + if (r.getBehavioralPattern() != null && r.getBehavioralPattern().getCommonCta() != null) + 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()) { + if (r.getSearchDemand().getPeakPeriods() != null && !r.getSearchDemand().getPeakPeriods().isEmpty()) sb.append("Пиковые периоды: ") .append(String.join(", ", r.getSearchDemand().getPeakPeriods())).append("\n"); - } } - if (r.getStructuredConclusions() != null && !r.getStructuredConclusions().isEmpty()) { - sb.append("\nКлючевые выводы анализа:\n"); + sb.append("\nКлючевые выводы:\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"); - } + if (r.getSmmStrategyRationale() != null && !r.getSmmStrategyRationale().isBlank()) + sb.append("\nСтратегическое обоснование:\n").append(r.getSmmStrategyRationale()).append("\n"); return sb.toString(); } - /** - * Рассчитывает точное количество постов каждого типа. - * Даёт AI конкретный план вместо абстрактных процентов. - */ private String buildPostCountPlan(StrategyModel model, int weeks) { - int total = weeks * 3; // минимум 3 поста/нед - int optimal = weeks * 4; // оптимально 4 поста/нед - + int total = weeks * 3; + int 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(" (").append(weeks).append(" нед.)\n\n"); + sb.append("Распределение для ").append(total).append(" постов:\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.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("\n Форматы: видео=").append(pct(total, 0.40)) - .append(", фото=").append(total - pct(total, 0.40)).append("\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 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.10)).append(" постов (10%)\n"); - sb.append(" Продающий: ").append(pct(total, 0.10)).append(" постов (10%)\n"); - sb.append("\n Форматы: видео=").append(pct(total, 0.35)) - .append(", фото=").append(total - pct(total, 0.35)).append("\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"); } 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("\n Форматы: видео=").append(pct(total, 0.50)) - .append(" (ПРИОРИТЕТ), фото=").append(total - pct(total, 0.50)).append("\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.10)).append(" постов (10%)\n"); - sb.append(" Объясняющий: ").append(pct(total, 0.05)).append(" постов (5%)\n"); - sb.append("\n Форматы: видео=").append(pct(total, 0.60)) - .append(" (ПРИОРИТЕТ), фото=").append(total - pct(total, 0.60)).append("\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("\nМАКСИМУМ 2 видео-поста на весь план! Остальные — фото.\n"); return sb.toString(); } @@ -736,7 +574,7 @@ public class MarketingStrategyV3Service { } // ===================================================================== - // MEDIA GENERATION + // MEDIA GENERATION — промпты на русском, KZ-контекст // ===================================================================== @SuppressWarnings("unchecked") @@ -745,6 +583,10 @@ public class MarketingStrategyV3Service { 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() : "Казахстан"; List refFiles = new ArrayList<>(); if (strategy.getStrategyData() != null @@ -761,10 +603,8 @@ public class MarketingStrategyV3Service { String contentType = item.getContentType() != null ? item.getContentType().toLowerCase() : "фото"; - log.info("[Media {}/{}] type={} theme={}", - current, total, contentType, item.getTheme()); + log.info("[Media {}/{}] type={} theme={}", current, total, contentType, item.getTheme()); - // Загружаем референсный файл (логотип/стиль клиента) byte[] refBytes = null; if (!refFiles.isEmpty()) { String selectedRef = refFiles.get(random.nextInt(refFiles.size())); @@ -775,9 +615,23 @@ 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.contains("видео")) { - doGenerateVideo(item, businessContext, niche); + doGenerateVideo(item, businessContext, niche, mainCity); } else { doGenerateImage(item, businessContext, brandName, niche, refBytes); } @@ -788,23 +642,49 @@ public class MarketingStrategyV3Service { } } + /** + * Генерация видео через Veo 3. + * Промпт ПОЛНОСТЬЮ на русском языке — для казахстанского рынка. + * Veo 3 нативно добавляет звук (музыку, атмосферу) без доп. настроек. + */ private void doGenerateVideo(MarketingStrategy.PostCalendarItem item, - String businessContext, String niche) { - String theme = item.getTheme() != null ? item.getTheme() : "Commercial content"; + String businessContext, String niche, String city) { + String theme = item.getTheme() != null ? item.getTheme() : "Презентация продукта"; String platform = item.getPlatform() != null ? item.getPlatform().toLowerCase() : "instagram"; - String styleHint = platform.contains("tiktok") - ? "trendy TikTok style, fast cuts, text overlays, Gen-Z aesthetic, viral potential" - : "Instagram Reels style, aesthetic, smooth transitions, lifestyle feel, high-end"; + // Стиль и звук по платформе — описание на русском + String styleDesc; + String audioDesc; + switch (platform) { + case "tiktok" -> { + styleDesc = "динамичный TikTok-стиль, вертикальный формат, быстрый монтаж, энергичная подача, трендовые переходы"; + audioDesc = "энергичная трендовая музыка в современном стиле, подходящая для казахстанской аудитории"; + } + case "telegram" -> { + styleDesc = "лаконичный информативный стиль, чёткая подача материала, профессиональный вид"; + audioDesc = "спокойная фоновая музыка или закадровый голос на русском языке"; + } + default -> { // instagram + styleDesc = "кинематографичный Instagram Reels стиль, плавные движения камеры, эстетичная картинка, современный lifestyle"; + audioDesc = "мягкая современная фоновая музыка, атмосферные звуки или закадровый голос на русском языке"; + } + } String videoPrompt = String.format( - "High quality engaging vertical 9:16 video for %s business (niche: %s). " + - "Main theme: %s. Style: %s. " + - "Photorealistic, bright professional lighting, dynamic motion, brand-appropriate.", - businessContext, niche, theme, styleHint); + "Кинематографичное вертикальное видео 9:16 для социальных сетей Казахстана. " + + "Бизнес: %s. Ниша: %s. Город: %s. " + + "Тема видео: «%s». " + + "Стиль: %s. " + + "Звуковое оформление: %s. " + + "Технические требования: фотореалистичное качество 8K, профессиональное освещение, " + + "правильная анатомия людей, естественные движения, натуральные эмоции. " + + "Люди — жители современного казахстанского города (Алматы/Астана/Шымкент), " + + "стильный образ жизни, уютная обстановка, натуральные цвета и тёплый свет. " + + "Без текстовых оверлеев, без водяных знаков, без субтитров.", + businessContext, niche, city, theme, styleDesc, audioDesc); try { - log.info("[VEO] Generating video for theme '{}'", theme); + log.info("[VEO3] Generating video. Platform={}, Theme='{}'", platform, theme); byte[] videoBytes = geminiVideoService.generateVideo(videoPrompt); if (videoBytes != null && videoBytes.length > 0) { @@ -815,19 +695,22 @@ public class MarketingStrategyV3Service { item.setVideoFilename(filename); item.setImageUrl(null); item.setImageFilename(null); - log.info("[VEO] Saved as {}", filename); + log.info("[VEO3] Saved: {} ({} bytes)", filename, videoBytes.length); } else { - log.warn("[VEO] Empty response for '{}'", theme); + log.warn("[VEO3] Empty response for '{}'", theme); item.setVideoFilename(null); item.setVideoUrl(null); } } catch (Exception e) { - log.error("[VEO] Error for '{}': {}", theme, e.getMessage(), e); + log.error("[VEO3] Error for '{}': {}", theme, e.getMessage(), e); item.setVideoFilename(null); item.setVideoUrl(null); } } + /** + * Генерация изображений — промпт на русском, казахстанский контекст. + */ private void doGenerateImage(MarketingStrategy.PostCalendarItem item, String businessContext, String brandName, String niche, byte[] refBytes) { @@ -843,7 +726,7 @@ public class MarketingStrategyV3Service { item.setVideoUrl(null); item.setVideoFilename(null); } else { - log.warn("[Image] Empty response for theme: {}", item.getTheme()); + log.warn("[Image] Empty response for: {}", item.getTheme()); item.setImageFilename(null); item.setImageUrl(null); } @@ -854,32 +737,39 @@ public class MarketingStrategyV3Service { } } + /** + * Промпт для изображений — на русском с казахстанским контекстом. + */ private String buildImagePrompt(MarketingStrategy.PostCalendarItem item, String businessContext, String brandName, String niche) { StringBuilder prompt = new StringBuilder(); String platform = item.getPlatform() != null ? item.getPlatform().toLowerCase() : "instagram"; - if (platform.contains("linkedin")) { - prompt.append("Professional corporate photography, clean minimalist layout, " + - "business context, soft natural lighting, editorial style. "); - } else if (platform.contains("tiktok")) { - prompt.append("Bold vibrant colors, Gen-Z aesthetic, dynamic composition, " + - "trendy modern style, high contrast, eye-catching vertical format. "); - } else { - // Instagram default - prompt.append("High-end commercial photography, photorealistic, professional lighting, " + - "aesthetic Instagram-style, lifestyle feel, clean background. "); + switch (platform) { + case "linkedin" -> prompt.append( + "Кинематографичная, masterpiece, 8K. Профессиональная корпоративная фотография, " + + "чистый минималистичный дизайн, деловой контекст, мягкое естественное освещение, " + + "современный офис или деловая встреча, высокая детализация. "); + case "tiktok" -> prompt.append( + "Кинематографичная, masterpiece, 8K. Яркие насыщенные цвета, молодёжная эстетика, " + + "динамичная композиция, трендовый современный стиль, высокий контраст, " + + "энергичная подача, высокая детализация. "); + default -> prompt.append( + "Кинематографичная, masterpiece, 8K. Коммерческая фотография высокого класса, " + + "профессиональное студийное освещение, эстетика Instagram, лайфстайл атмосфера, " + + "современный казахстанский контекст, чистый фон, высокая детализация. "); } - prompt.append("Business: ").append(businessContext).append(". "); - prompt.append("Niche: ").append(niche).append(". "); + prompt.append("Бизнес: ").append(businessContext).append(". "); + prompt.append("Ниша: ").append(niche).append(". "); - if (item.getTheme() != null && !item.getTheme().isBlank()) { - prompt.append("Visual concept: ").append(item.getTheme()).append(". "); - } + if (item.getTheme() != null && !item.getTheme().isBlank()) + prompt.append("Визуальная концепция: ").append(item.getTheme()).append(". "); - prompt.append("Product/service is the main focus. People look natural and authentic. " + - "No text overlays. No watermarks. Brand: ").append(brandName).append("."); + prompt.append("Жители Казахстана — современный образ жизни, естественный и аутентичный. ") + .append("Продукт или услуга в главном фокусе. Люди выглядят естественно и счастливо. ") + .append("Без текстовых надписей на изображении. Без водяных знаков. ") + .append("Бренд: ").append(brandName).append("."); return prompt.toString(); } @@ -930,9 +820,7 @@ public class MarketingStrategyV3Service { public void regeneratePostVideoAsync(String strategyId, int postIndex) { try { MarketingStrategy strategy = repository.findById(strategyId).orElse(null); - if (strategy == null) { - log.error("[VideoRegen] Strategy {} not found", strategyId); return; - } + if (strategy == null) { log.error("[VideoRegen] Strategy {} not found", strategyId); return; } List calendar = strategy.getPostCalendar(); if (calendar == null || postIndex < 0 || postIndex >= calendar.size()) { @@ -949,13 +837,16 @@ public class MarketingStrategyV3Service { MarketingAnalysisV3Document analysis = analysisRepository .findById(strategy.getAnalysisId()).orElse(null); - if (analysis == null) { - log.error("[VideoRegen] Analysis not found"); return; - } + if (analysis == null) { log.error("[VideoRegen] Analysis not found"); return; } + + String city = analysis.getRequestData().getMainCity() != null + && !analysis.getRequestData().getMainCity().isBlank() + ? analysis.getRequestData().getMainCity() : "Казахстан"; doGenerateVideo(item, getBusinessContext(analysis), - analysis.getRequestData().getBusinessNiche()); + analysis.getRequestData().getBusinessNiche(), + city); repository.save(strategy); log.info("[VideoRegen] Done for post {} in strategy {}", postIndex, strategyId); @@ -965,7 +856,7 @@ public class MarketingStrategyV3Service { } // ===================================================================== - // STRATEGY RESULT + // STRATEGY RESULT — scoringModelName и scoringModelTitle в корне ответа // ===================================================================== public Map getStrategyResult(String strategyId) { @@ -973,14 +864,31 @@ 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("strategyData", strategy.getStrategyData()); + 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()); + + // ── Название модели — всегда в корне, фронт читает отсюда ────────── + 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(); + } + if (modelTitle == null && strategy.getStrategyData() != null) { + Object v = strategy.getStrategyData().get("scoringModelTitle"); + if (v != null) modelTitle = v.toString(); + } + response.put("scoringModelName", modelName); + response.put("scoringModelTitle", modelTitle); + // ─────────────────────────────────────────────────────────────────── + + response.put("strategyData", strategy.getStrategyData()); if (strategy.getWeeklyPlans() != null && strategy.getPostCalendar() != null) { List tasks = postingTaskService.getStrategyTasks(strategyId); @@ -1018,7 +926,8 @@ 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()); @@ -1038,17 +947,20 @@ public class MarketingStrategyV3Service { } @SuppressWarnings("unchecked") - private void populateStrategyEntity(MarketingStrategy strategy, Map data) { + private void populateStrategyEntity(MarketingStrategy strategy, + Map data, + ScoringResult scoring) { strategy.setStrategyData(data); - if (data.containsKey("recommendedDurationWeeks")) { + // Сохраняем название модели прямо в поля entity + strategy.setScoringModelName(scoring.getModel().name()); + strategy.setScoringModelTitle(scoring.getModel().getTitle()); + + if (data.containsKey("recommendedDurationWeeks")) strategy.setDurationWeeks((Integer) data.get("recommendedDurationWeeks")); - } - if (data.containsKey("recommendedPlatforms")) { + if (data.containsKey("recommendedPlatforms")) strategy.setPriorityPlatforms((List) data.get("recommendedPlatforms")); - } - // Weekly plans List> weeksRaw = (List>) data.get("weeklyPlans"); List weeklyPlans = new ArrayList<>(); if (weeksRaw != null) { @@ -1063,7 +975,6 @@ public class MarketingStrategyV3Service { } strategy.setWeeklyPlans(weeklyPlans); - // Post calendar List> calendarRaw = (List>) data.get("postCalendar"); List postCalendar = new ArrayList<>(); LocalDateTime startDate = LocalDateTime.now().plusDays(1); @@ -1071,7 +982,6 @@ public class MarketingStrategyV3Service { if (calendarRaw != null) { for (Map c : calendarRaw) { MarketingStrategy.PostCalendarItem item = new MarketingStrategy.PostCalendarItem(); - String dateStr = (String) c.get("publishDate"); if (dateStr != null) { try { @@ -1083,21 +993,19 @@ public class MarketingStrategyV3Service { } else { item.setPublishDate(startDate.plusDays(postCalendar.size() * 2L)); } - 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")); item.setPublishTime((String) c.get("publishTime")); - postCalendar.add(item); } } strategy.setPostCalendar(postCalendar); - log.info("Populated: {} weeklyPlans, {} postCalendar items", - weeklyPlans.size(), postCalendar.size()); + log.info("Populated: {} weeklyPlans, {} posts, model={}", + weeklyPlans.size(), postCalendar.size(), scoring.getModel().name()); } 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 7f551c6..0c77313 100644 --- a/src/main/java/kz/konturai/parser/service/NanoBananaImageGenerationService.java +++ b/src/main/java/kz/konturai/parser/service/NanoBananaImageGenerationService.java @@ -191,7 +191,7 @@ public class NanoBananaImageGenerationService implements ImageGenerationService 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, nsfw, watermark"); + 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;