This commit is contained in:
arys
2026-03-20 13:31:44 +05:00
parent 39b3885dd3
commit 2c9944d14e
10 changed files with 1050 additions and 790 deletions
+14
View File
@@ -171,6 +171,20 @@
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.14.0</version>
<configuration>
<annotationProcessorPaths>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.36</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
@@ -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());
@@ -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<String> strengths = List.of();
private List<String> 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<String> 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<String, Double> starDistribution = Map.of();
private double highTrustBusinessShare = 0.0;
private int avgOwnerResponseTimeHours = 0;
private double highTrustBusinessShare = 0.0;
private int avgOwnerResponseTimeHours = 0;
private List<String> topComplaintThemes = List.of();
private List<String> topPraiseThemes = List.of();
}
@Data
@@ -133,16 +140,19 @@ public class MarketingAnalysisV3Result {
@Data
public static class SearchDemand {
private int avgMonthlySearches = 0;
private List<TimeSeriesPoint> seasonality = List.of();
private List<String> peakPeriods = List.of();
private List<String> relatedQueries = List.of();
private int avgMonthlySearches = 0;
private String trendDirection = "";
private double yoyGrowthPercent = 0.0;
private List<TimeSeriesPoint> seasonality = List.of();
private List<String> peakPeriods = List.of();
private List<String> 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<String> strategicFocus = List.of();
}
@@ -19,8 +19,21 @@ public class StrategyHistoryResponse {
private String userId;
private Integer durationWeeks;
private List<String> 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<StatusHistoryEntry> statusHistory;
}
}
@@ -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;
@@ -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<String, Object> instance = new HashMap<>();
instance.put("prompt", prompt);
Map<String, Object> 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<String, Object> 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<String, Object> initResponse;
try {
@@ -139,29 +161,27 @@ public class GeminiVideoGenerationService {
.bodyToMono(new ParameterizedTypeReference<Map<String, Object>>() {})
.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<String, Object> 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<String, Object> responseObj = (Map<String, Object>) statusResponse.get("response");
Map<String, Object> responseObj =
(Map<String, Object>) 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<Map<String, Object>> predictions = (List<Map<String, Object>>) responseObj.get("predictions");
List<Map<String, Object>> predictions =
(List<Map<String, Object>>) responseObj.get("predictions");
if (predictions != null && !predictions.isEmpty()) {
Map<String, Object> firstPrediction = predictions.get(0);
logger.info("VEO: First prediction keys: {}", firstPrediction.keySet());
Map<String, Object> 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")
@@ -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<String, Object> 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<Object> 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<String> ranges = (List<String>) 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<String> resolveCompetitorCompaniesForBenchmarks(MarketingAnalysisRequest request,
Map<String, Object> researchPack) {
Map<String, Object> researchPack) {
int max = Math.max(5, Math.min(5, 15));
LinkedHashSet<String> 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<String> competitorCompanies) {
List<String> 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<String, Integer> 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<String, String> 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<String, Integer> 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<String, MarketingAnalysisResponseV2.ChannelEfficiency> 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<String, Object> generateJsonAnalysis(MarketingAnalysisRequest request) {
Map<String, Object> result = new HashMap<>();
@@ -2061,7 +2097,6 @@ public class MarketingAnalysisService {
return new HashMap<>();
}
private Map<String, Object> 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<MarketingAnalysis> getUserAnalyses(String userId) {
logger.debug("Fetching analyses for userId: {}", userId);
try {
// 1. Получаем стандартные анализы (V1 и те, что были созданы через startAnalysis)
// 1. Получаем стандартные анализы (V1 и те, что были созданы через
// startAnalysis)
List<MarketingAnalysis> v1Analyses = repository.findByUserIdOrderByCreatedAtDesc(userId);
List<MarketingAnalysisV2Document> 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<MarketingAnalysis> 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);
@@ -111,13 +111,15 @@ public class MarketingAnalysisV3Service {
// Простые конкретные запросы — Serper работает надёжнее без OR-site-нагромождений
Map<String, String> 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<String, SerperSearchResult> 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,000200,000.
- Постов в месяц: 4–25.
- avgMonthlySearches: узкая ниша 500–5,000/мес, популярная 10,00080,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,000100,000
• Посты/месяц: 6–20 (типично: 8–14)
• avgMonthlySearches узкая ниша: 300–5,000 | широкая: 8,00080,000
• ciiIndex: целое число 0–100 СТРОГО
• densityIndex: дробное 0.0100.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:0021: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:0021: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));
}
File diff suppressed because it is too large Load Diff
@@ -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;