fix
This commit is contained in:
@@ -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,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));
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user