fix
This commit is contained in:
@@ -171,6 +171,20 @@
|
|||||||
|
|
||||||
<build>
|
<build>
|
||||||
<plugins>
|
<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>
|
<plugin>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||||
|
|||||||
@@ -574,6 +574,59 @@ public class MarketingAnalysisV3Controller {
|
|||||||
response.setUserId(strategy.getUserId());
|
response.setUserId(strategy.getUserId());
|
||||||
response.setDurationWeeks(strategy.getDurationWeeks());
|
response.setDurationWeeks(strategy.getDurationWeeks());
|
||||||
response.setPriorityPlatforms(strategy.getPriorityPlatforms());
|
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.setCreatedAt(strategy.getCreatedAt());
|
||||||
response.setCompletedAt(strategy.getCompletedAt());
|
response.setCompletedAt(strategy.getCompletedAt());
|
||||||
response.setStatusHistory(strategy.getStatusHistory());
|
response.setStatusHistory(strategy.getStatusHistory());
|
||||||
|
|||||||
@@ -83,13 +83,16 @@ public class MarketingAnalysisV3Result {
|
|||||||
|
|
||||||
@Data
|
@Data
|
||||||
public static class CompetitorProfile {
|
public static class CompetitorProfile {
|
||||||
private String name = "";
|
private String name = "";
|
||||||
private String platform = "";
|
private String platform = "";
|
||||||
private int followers = 0;
|
private String profileUrl = "";
|
||||||
private int postsPerMonth = 0;
|
private int followers = 0;
|
||||||
private double er = 0.0;
|
private int postsPerMonth = 0;
|
||||||
private double rating = 0.0;
|
private double er = 0.0;
|
||||||
private int reviews = 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> strengths = List.of();
|
||||||
private List<String> weaknesses = List.of();
|
private List<String> weaknesses = List.of();
|
||||||
}
|
}
|
||||||
@@ -104,6 +107,8 @@ public class MarketingAnalysisV3Result {
|
|||||||
private double videoShare = 0.0;
|
private double videoShare = 0.0;
|
||||||
private int avgTextLengthCharacters = 0;
|
private int avgTextLengthCharacters = 0;
|
||||||
private double ctaUsagePercent = 0.0;
|
private double ctaUsagePercent = 0.0;
|
||||||
|
private List<String> topHashtags = List.of();
|
||||||
|
private String bestPostingTime = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
@Data
|
@Data
|
||||||
@@ -115,11 +120,13 @@ public class MarketingAnalysisV3Result {
|
|||||||
|
|
||||||
@Data
|
@Data
|
||||||
public static class ReputationAnalysis {
|
public static class ReputationAnalysis {
|
||||||
private double avgNicheRating = 0.0;
|
private double avgNicheRating = 0.0;
|
||||||
private int medianReviews = 0;
|
private int medianReviews = 0;
|
||||||
private Map<String, Double> starDistribution = Map.of();
|
private Map<String, Double> starDistribution = Map.of();
|
||||||
private double highTrustBusinessShare = 0.0;
|
private double highTrustBusinessShare = 0.0;
|
||||||
private int avgOwnerResponseTimeHours = 0;
|
private int avgOwnerResponseTimeHours = 0;
|
||||||
|
private List<String> topComplaintThemes = List.of();
|
||||||
|
private List<String> topPraiseThemes = List.of();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Data
|
@Data
|
||||||
@@ -133,16 +140,19 @@ public class MarketingAnalysisV3Result {
|
|||||||
|
|
||||||
@Data
|
@Data
|
||||||
public static class SearchDemand {
|
public static class SearchDemand {
|
||||||
private int avgMonthlySearches = 0;
|
private int avgMonthlySearches = 0;
|
||||||
private List<TimeSeriesPoint> seasonality = List.of();
|
private String trendDirection = "";
|
||||||
private List<String> peakPeriods = List.of();
|
private double yoyGrowthPercent = 0.0;
|
||||||
private List<String> relatedQueries = List.of();
|
private List<TimeSeriesPoint> seasonality = List.of();
|
||||||
|
private List<String> peakPeriods = List.of();
|
||||||
|
private List<String> relatedQueries = List.of();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Data
|
@Data
|
||||||
public static class UserPositioning {
|
public static class UserPositioning {
|
||||||
private RadarMetrics marketMetrics = new RadarMetrics();
|
private RadarMetrics marketMetrics = new RadarMetrics();
|
||||||
private RadarMetrics userMetrics = new RadarMetrics();
|
private RadarMetrics userMetrics = new RadarMetrics();
|
||||||
|
private RadarMetrics gap = new RadarMetrics();
|
||||||
private String mode = "BENCHMARK";
|
private String mode = "BENCHMARK";
|
||||||
private List<String> strategicFocus = List.of();
|
private List<String> strategicFocus = List.of();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,8 +19,21 @@ public class StrategyHistoryResponse {
|
|||||||
private String userId;
|
private String userId;
|
||||||
private Integer durationWeeks;
|
private Integer durationWeeks;
|
||||||
private List<String> priorityPlatforms;
|
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 createdAt;
|
||||||
private LocalDateTime completedAt;
|
private LocalDateTime completedAt;
|
||||||
private List<StatusHistoryEntry> statusHistory;
|
private List<StatusHistoryEntry> statusHistory;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,6 +24,16 @@ public class MarketingStrategy {
|
|||||||
@Field("status")
|
@Field("status")
|
||||||
private String status; // queued, processing, completed, failed
|
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")
|
@Field("duration_weeks")
|
||||||
private Integer durationWeeks;
|
private Integer durationWeeks;
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,14 @@ import java.net.URI;
|
|||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Сервис генерации видео через Google Veo 3.
|
||||||
|
*
|
||||||
|
* КЛЮЧЕВЫЕ ОСОБЕННОСТИ Veo 3:
|
||||||
|
* - Нативный звук: музыка, диалоги, фоновые звуки — без доп. настроек
|
||||||
|
* - enhancePrompt = false — ОБЯЗАТЕЛЬНО, иначе Google переводит промпт на английский
|
||||||
|
* - Все промпты передаются на русском языке для казахстанского рынка
|
||||||
|
*/
|
||||||
@Service
|
@Service
|
||||||
public class GeminiVideoGenerationService {
|
public class GeminiVideoGenerationService {
|
||||||
|
|
||||||
@@ -40,7 +48,7 @@ public class GeminiVideoGenerationService {
|
|||||||
@Value("${google.cloud.location:us-central1}")
|
@Value("${google.cloud.location:us-central1}")
|
||||||
private String location;
|
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;
|
private String model;
|
||||||
|
|
||||||
public GeminiVideoGenerationService() {
|
public GeminiVideoGenerationService() {
|
||||||
@@ -49,7 +57,8 @@ public class GeminiVideoGenerationService {
|
|||||||
.responseTimeout(Duration.ofMinutes(20));
|
.responseTimeout(Duration.ofMinutes(20));
|
||||||
|
|
||||||
ExchangeStrategies strategies = ExchangeStrategies.builder()
|
ExchangeStrategies strategies = ExchangeStrategies.builder()
|
||||||
.codecs(configurer -> configurer.defaultCodecs().maxInMemorySize(500 * 1024 * 1024)) // 500MB
|
.codecs(configurer -> configurer.defaultCodecs()
|
||||||
|
.maxInMemorySize(500 * 1024 * 1024))
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
this.webClient = WebClient.builder()
|
this.webClient = WebClient.builder()
|
||||||
@@ -65,39 +74,48 @@ public class GeminiVideoGenerationService {
|
|||||||
if (resource.exists()) {
|
if (resource.exists()) {
|
||||||
try (InputStream is = resource.getInputStream()) {
|
try (InputStream is = resource.getInputStream()) {
|
||||||
this.credentials = GoogleCredentials.fromStream(is)
|
this.credentials = GoogleCredentials.fromStream(is)
|
||||||
.createScoped(Collections.singletonList("https://www.googleapis.com/auth/cloud-platform"));
|
.createScoped(Collections.singletonList(
|
||||||
logger.info("VEO: Credentials loaded. Project={}, Location={}, Model={}", projectId, location, model);
|
"https://www.googleapis.com/auth/cloud-platform"));
|
||||||
|
logger.info("VEO3: Credentials loaded. Project={}, Location={}, Model={}",
|
||||||
|
projectId, location, model);
|
||||||
}
|
}
|
||||||
} else {
|
} 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) {
|
} catch (IOException e) {
|
||||||
logger.error("VEO: Error reading credentials: {}", e.getMessage());
|
logger.error("VEO3: Error reading credentials: {}", e.getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private String getValidAccessToken() {
|
private String getValidAccessToken() {
|
||||||
if (this.credentials == null) {
|
if (this.credentials == null) {
|
||||||
logger.error("VEO: Credentials not initialized!");
|
logger.error("VEO3: Credentials not initialized!");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
this.credentials.refreshIfExpired();
|
this.credentials.refreshIfExpired();
|
||||||
String token = this.credentials.getAccessToken().getTokenValue();
|
String token = this.credentials.getAccessToken().getTokenValue();
|
||||||
if (token == null || token.isEmpty()) {
|
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 null;
|
||||||
}
|
}
|
||||||
return token;
|
return token;
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
logger.error("VEO: Token refresh error: {}", e.getMessage());
|
logger.error("VEO3: Token refresh error: {}", e.getMessage());
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Генерирует видео по промпту на РУССКОМ языке.
|
||||||
|
*
|
||||||
|
* Промпт формируется в MarketingStrategyV3Service.doGenerateVideo()
|
||||||
|
* с учётом казахстанского контекста, города, платформы и темы поста.
|
||||||
|
* Veo 3 автоматически добавляет звук (музыку/диалоги) к видео.
|
||||||
|
*/
|
||||||
public byte[] generateVideo(String prompt) {
|
public byte[] generateVideo(String prompt) {
|
||||||
if (projectId == null || projectId.trim().isEmpty()) {
|
if (projectId == null || projectId.trim().isEmpty()) {
|
||||||
logger.error("VEO: Project ID is not configured!");
|
logger.error("VEO3: Project ID is not configured!");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,28 +123,32 @@ public class GeminiVideoGenerationService {
|
|||||||
if (accessToken == null) return null;
|
if (accessToken == null) return null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// ============================================================
|
// ═══ ШАГ 1: Запуск генерации через predictLongRunning ════════════
|
||||||
// ШАГ 1: Запускаем генерацию через predictLongRunning
|
|
||||||
// ============================================================
|
|
||||||
String generateEndpoint = String.format(
|
String generateEndpoint = String.format(
|
||||||
"https://%s-aiplatform.googleapis.com/v1/projects/%s/locations/%s/publishers/google/models/%s:predictLongRunning",
|
"https://%s-aiplatform.googleapis.com/v1/projects/%s/locations/%s/publishers/google/models/%s:predictLongRunning",
|
||||||
location, projectId, location, model);
|
location, projectId, location, model);
|
||||||
|
|
||||||
// Структура запроса для Veo 2.0
|
|
||||||
Map<String, Object> instance = new HashMap<>();
|
Map<String, Object> instance = new HashMap<>();
|
||||||
instance.put("prompt", prompt);
|
instance.put("prompt", prompt);
|
||||||
|
|
||||||
Map<String, Object> parameters = new HashMap<>();
|
Map<String, Object> parameters = new HashMap<>();
|
||||||
parameters.put("aspectRatio", "9:16");
|
parameters.put("aspectRatio", "9:16");
|
||||||
parameters.put("sampleCount", 1);
|
parameters.put("sampleCount", 1);
|
||||||
// Можно добавить: parameters.put("durationSeconds", 8);
|
// КРИТИЧНО: false — не давать Google переводить промпт на английский
|
||||||
|
parameters.put("enhancePrompt", false);
|
||||||
|
// Негативный промпт тоже на русском
|
||||||
|
parameters.put("negativePrompt",
|
||||||
|
"размытое изображение, плохая анатомия, лишние конечности, деформированные лица, " +
|
||||||
|
"мультяшный стиль, 3D рендер, искусственный вид, субтитры на английском, " +
|
||||||
|
"водяной знак, нереалистичное движение, некрасивые руки, лишние пальцы");
|
||||||
|
|
||||||
Map<String, Object> requestBody = new HashMap<>();
|
Map<String, Object> requestBody = new HashMap<>();
|
||||||
requestBody.put("instances", Collections.singletonList(instance));
|
requestBody.put("instances", Collections.singletonList(instance));
|
||||||
requestBody.put("parameters", parameters);
|
requestBody.put("parameters", parameters);
|
||||||
|
|
||||||
logger.info("VEO: Starting generation. Endpoint: {}", generateEndpoint);
|
logger.info("VEO3: Starting generation. Model={}, Endpoint={}", model, generateEndpoint);
|
||||||
logger.info("VEO: Prompt: '{}'", prompt);
|
logger.info("VEO3: Prompt (preview 300): '{}'",
|
||||||
|
prompt.length() > 300 ? prompt.substring(0, 300) + "..." : prompt);
|
||||||
|
|
||||||
Map<String, Object> initResponse;
|
Map<String, Object> initResponse;
|
||||||
try {
|
try {
|
||||||
@@ -139,29 +161,27 @@ public class GeminiVideoGenerationService {
|
|||||||
.bodyToMono(new ParameterizedTypeReference<Map<String, Object>>() {})
|
.bodyToMono(new ParameterizedTypeReference<Map<String, Object>>() {})
|
||||||
.block(Duration.ofSeconds(120));
|
.block(Duration.ofSeconds(120));
|
||||||
} catch (WebClientResponseException e) {
|
} 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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (initResponse == null) {
|
if (initResponse == null) {
|
||||||
logger.error("VEO: Initial response is null!");
|
logger.error("VEO3: Initial response is null!");
|
||||||
return 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");
|
String operationName = (String) initResponse.get("name");
|
||||||
if (operationName == null || operationName.isEmpty()) {
|
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;
|
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(
|
String fetchEndpoint = String.format(
|
||||||
"https://%s-aiplatform.googleapis.com/v1/projects/%s/locations/%s/publishers/google/models/%s:fetchPredictOperation",
|
"https://%s-aiplatform.googleapis.com/v1/projects/%s/locations/%s/publishers/google/models/%s:fetchPredictOperation",
|
||||||
location, projectId, location, model);
|
location, projectId, location, model);
|
||||||
@@ -169,16 +189,16 @@ public class GeminiVideoGenerationService {
|
|||||||
Map<String, Object> fetchBody = new HashMap<>();
|
Map<String, Object> fetchBody = new HashMap<>();
|
||||||
fetchBody.put("operationName", operationName);
|
fetchBody.put("operationName", operationName);
|
||||||
|
|
||||||
int attempts = 0;
|
int attempts = 0;
|
||||||
int maxAttempts = 120; // 30 минут (120 * 15 сек)
|
int maxAttempts = 120; // 30 минут (120 × 15 сек)
|
||||||
|
|
||||||
while (attempts < maxAttempts) {
|
while (attempts < maxAttempts) {
|
||||||
Thread.sleep(15000); // 15 секунд между попытками
|
Thread.sleep(15000);
|
||||||
attempts++;
|
attempts++;
|
||||||
|
|
||||||
String currentToken = getValidAccessToken();
|
String currentToken = getValidAccessToken();
|
||||||
if (currentToken == null) {
|
if (currentToken == null) {
|
||||||
logger.warn("VEO: Could not get token on attempt {}, skipping...", attempts);
|
logger.warn("VEO3: No token on attempt {}, skipping...", attempts);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,125 +213,97 @@ public class GeminiVideoGenerationService {
|
|||||||
.block(Duration.ofSeconds(60));
|
.block(Duration.ofSeconds(60));
|
||||||
|
|
||||||
if (statusResponse == null) {
|
if (statusResponse == null) {
|
||||||
logger.warn("VEO: Null status response on attempt {}", attempts);
|
logger.warn("VEO3: Null status on attempt {}", attempts);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Всегда логируем сырой ответ для диагностики
|
|
||||||
String rawJson = objectMapper.writeValueAsString(statusResponse);
|
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")) {
|
if (statusResponse.containsKey("error")) {
|
||||||
Object errorObj = statusResponse.get("error");
|
logger.error("VEO3: Google returned error: {}",
|
||||||
logger.error("VEO: Google returned error: {}", objectMapper.writeValueAsString(errorObj));
|
objectMapper.writeValueAsString(statusResponse.get("error")));
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Проверяем done
|
|
||||||
Object doneObj = statusResponse.get("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) {
|
if (!isDone) {
|
||||||
logger.info("VEO: Still processing... attempt {}/{}", attempts, maxAttempts);
|
logger.info("VEO3: Still processing... {}/{}", attempts, maxAttempts);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info("VEO: Operation DONE on attempt {}! Extracting video...", attempts);
|
logger.info("VEO3: DONE on attempt {}! Extracting...", attempts);
|
||||||
|
|
||||||
// ============================================================
|
// ═══ ШАГ 3: Извлечение видео ═════════════════════════════
|
||||||
// ШАГ 3: Извлекаем видео из ответа
|
|
||||||
// ============================================================
|
|
||||||
// Структура ответа Veo:
|
|
||||||
// {
|
|
||||||
// "done": true,
|
|
||||||
// "response": {
|
|
||||||
// "@type": "...",
|
|
||||||
// "predictions": [
|
|
||||||
// {
|
|
||||||
// "bytesBase64Encoded": "...", <- вариант 1
|
|
||||||
// "videoUri": "gs://...", <- вариант 2
|
|
||||||
// "mimeType": "video/mp4"
|
|
||||||
// }
|
|
||||||
// ]
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
// Достаем объект response
|
|
||||||
@SuppressWarnings("unchecked")
|
@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) {
|
if (responseObj == null) {
|
||||||
// Иногда данные могут быть прямо в корне при done=true
|
logger.warn("VEO3: No 'response' field, trying root...");
|
||||||
logger.warn("VEO: No 'response' field, trying root object...");
|
|
||||||
responseObj = statusResponse;
|
responseObj = statusResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Пробуем извлечь base64
|
// Вариант 1 — base64
|
||||||
String b64 = findFirstValue(responseObj, "bytesBase64Encoded");
|
String b64 = findFirstValue(responseObj, "bytesBase64Encoded");
|
||||||
if (b64 != null && !b64.isEmpty()) {
|
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);
|
return decodeBase64Video(b64);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Пробуем GCS URI
|
// Вариант 2 — GCS URI
|
||||||
String videoUri = findFirstValue(responseObj, "videoUri");
|
String videoUri = findFirstValue(responseObj, "videoUri");
|
||||||
if (videoUri == null) {
|
if (videoUri == null) videoUri = findFirstValue(responseObj, "uri");
|
||||||
videoUri = findFirstValue(responseObj, "uri");
|
|
||||||
}
|
|
||||||
if (videoUri != null && !videoUri.isEmpty()) {
|
if (videoUri != null && !videoUri.isEmpty()) {
|
||||||
logger.info("VEO: Found video URI: {}", videoUri);
|
logger.info("VEO3: Found video URI: {}", videoUri);
|
||||||
return downloadFromGcs(videoUri, currentToken);
|
return downloadFromGcs(videoUri, currentToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Пробуем найти в predictions напрямую
|
// Вариант 3 — predictions напрямую
|
||||||
@SuppressWarnings("unchecked")
|
@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()) {
|
if (predictions != null && !predictions.isEmpty()) {
|
||||||
Map<String, Object> firstPrediction = predictions.get(0);
|
Map<String, Object> first = predictions.get(0);
|
||||||
logger.info("VEO: First prediction keys: {}", firstPrediction.keySet());
|
logger.info("VEO3: Prediction keys: {}", first.keySet());
|
||||||
|
|
||||||
b64 = (String) firstPrediction.get("bytesBase64Encoded");
|
b64 = (String) first.get("bytesBase64Encoded");
|
||||||
if (b64 != null && !b64.isEmpty()) {
|
if (b64 != null && !b64.isEmpty()) return decodeBase64Video(b64);
|
||||||
return decodeBase64Video(b64);
|
|
||||||
}
|
|
||||||
|
|
||||||
String uri = (String) firstPrediction.get("videoUri");
|
String uri = (String) first.get("videoUri");
|
||||||
if (uri == null) uri = (String) firstPrediction.get("uri");
|
if (uri == null) uri = (String) first.get("uri");
|
||||||
if (uri != null && !uri.isEmpty()) {
|
if (uri != null && !uri.isEmpty()) return downloadFromGcs(uri, currentToken);
|
||||||
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;
|
return null;
|
||||||
|
|
||||||
} catch (WebClientResponseException e) {
|
} catch (WebClientResponseException e) {
|
||||||
logger.error("VEO: HTTP error on attempt {}: {} - {}",
|
logger.error("VEO3: HTTP error on attempt {}: {} — {}",
|
||||||
attempts, e.getStatusCode(), e.getResponseBodyAsString());
|
attempts, e.getStatusCode(), e.getResponseBodyAsString());
|
||||||
if (e.getStatusCode().is4xxClientError()) {
|
if (e.getStatusCode().is4xxClientError()) {
|
||||||
logger.error("VEO: 4xx error - stopping poll to avoid wasting time.");
|
logger.error("VEO3: 4xx — stopping poll.");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
// При 5xx продолжаем
|
// При 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;
|
return null;
|
||||||
|
|
||||||
} catch (InterruptedException e) {
|
} catch (InterruptedException e) {
|
||||||
Thread.currentThread().interrupt();
|
Thread.currentThread().interrupt();
|
||||||
logger.error("VEO: Thread interrupted during polling");
|
logger.error("VEO3: Thread interrupted");
|
||||||
return null;
|
return null;
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
logger.error("VEO: Fatal error: {}", e.getMessage(), e);
|
logger.error("VEO3: Fatal error: {}", e.getMessage(), e);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Декодируем base64 в байты. Чистим строку от пробелов и data URI префиксов.
|
|
||||||
*/
|
|
||||||
private byte[] decodeBase64Video(String b64) {
|
private byte[] decodeBase64Video(String b64) {
|
||||||
try {
|
try {
|
||||||
String clean = b64.replaceAll("\\s", "");
|
String clean = b64.replaceAll("\\s", "");
|
||||||
@@ -319,41 +311,27 @@ public class GeminiVideoGenerationService {
|
|||||||
clean = clean.substring(clean.indexOf("base64,") + 7);
|
clean = clean.substring(clean.indexOf("base64,") + 7);
|
||||||
}
|
}
|
||||||
byte[] bytes = Base64.getDecoder().decode(clean);
|
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;
|
return bytes;
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
logger.error("VEO: Failed to decode base64: {}", e.getMessage());
|
logger.error("VEO3: Failed to decode base64: {}", e.getMessage());
|
||||||
return null;
|
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) {
|
private byte[] downloadFromGcs(String uri, String accessToken) {
|
||||||
try {
|
try {
|
||||||
String downloadUrl;
|
String downloadUrl;
|
||||||
|
|
||||||
if (uri.startsWith("gs://")) {
|
if (uri.startsWith("gs://")) {
|
||||||
// Конвертируем gs:// в HTTPS URL
|
downloadUrl = "https://storage.googleapis.com/" + uri.substring(5);
|
||||||
// gs://bucket-name/path/to/file.mp4
|
logger.info("VEO3: GCS → HTTPS: {}", downloadUrl);
|
||||||
// -> 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);
|
|
||||||
} else if (uri.startsWith("https://")) {
|
} else if (uri.startsWith("https://")) {
|
||||||
downloadUrl = uri;
|
downloadUrl = uri;
|
||||||
} else {
|
} else {
|
||||||
logger.error("VEO: Unknown URI format: {}", uri);
|
logger.error("VEO3: Unknown URI format: {}", uri);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info("VEO: Downloading video from: {}", downloadUrl);
|
|
||||||
|
|
||||||
byte[] videoBytes = webClient.get()
|
byte[] videoBytes = webClient.get()
|
||||||
.uri(URI.create(downloadUrl))
|
.uri(URI.create(downloadUrl))
|
||||||
.header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken)
|
.header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken)
|
||||||
@@ -362,22 +340,18 @@ public class GeminiVideoGenerationService {
|
|||||||
.block(Duration.ofMinutes(5));
|
.block(Duration.ofMinutes(5));
|
||||||
|
|
||||||
if (videoBytes != null && videoBytes.length > 0) {
|
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;
|
return videoBytes;
|
||||||
} else {
|
} else {
|
||||||
logger.error("VEO: Downloaded empty bytes from GCS URI: {}", uri);
|
logger.error("VEO3: Empty download from: {}", uri);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
logger.error("VEO: Error downloading from GCS {}: {}", uri, e.getMessage());
|
logger.error("VEO3: Download error from {}: {}", uri, e.getMessage());
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Рекурсивный поиск значения по ключу в вложенных Map/List структурах.
|
|
||||||
*/
|
|
||||||
private String findFirstValue(Object obj, String targetKey) {
|
private String findFirstValue(Object obj, String targetKey) {
|
||||||
if (obj instanceof Map) {
|
if (obj instanceof Map) {
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import org.springframework.web.reactive.function.client.WebClientResponseExcepti
|
|||||||
|
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
|
@Deprecated
|
||||||
public class MarketingAnalysisService {
|
public class MarketingAnalysisService {
|
||||||
private static final String AI_MANDATORY_JSON_USAGE_WARNING = "ВНИМАНИЕ: Если в предоставленном JSON есть данные, ты ОБЯЗАН использовать их и вставить соответствующий тег [[CHART_...]]. "
|
private static final String AI_MANDATORY_JSON_USAGE_WARNING = "ВНИМАНИЕ: Если в предоставленном JSON есть данные, ты ОБЯЗАН использовать их и вставить соответствующий тег [[CHART_...]]. "
|
||||||
+ "Никогда не пиши, что данных нет, если JSON не пустой.\n\n";
|
+ "Никогда не пиши, что данных нет, если JSON не пустой.\n\n";
|
||||||
@@ -47,6 +48,7 @@ public class MarketingAnalysisService {
|
|||||||
+ "ГРАФИКИ (ОБЯЗАТЕЛЬНО): располагай теги графиков (например, [[CHART_...]]) непосредственно внутри или сразу после описания конкретных данных/цифр, к которым относится график.\n\n";
|
+ "ГРАФИКИ (ОБЯЗАТЕЛЬНО): располагай теги графиков (например, [[CHART_...]]) непосредственно внутри или сразу после описания конкретных данных/цифр, к которым относится график.\n\n";
|
||||||
|
|
||||||
private static final int REPORT_DETAILED_MAX_TOKENS = 12000;
|
private static final int REPORT_DETAILED_MAX_TOKENS = 12000;
|
||||||
|
|
||||||
private DetailLevel resolveDetailLevelFromContext(Map<String, Object> contextJson) {
|
private DetailLevel resolveDetailLevelFromContext(Map<String, Object> contextJson) {
|
||||||
if (contextJson == null) {
|
if (contextJson == null) {
|
||||||
return 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";
|
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<>();
|
List<Object> general = new ArrayList<>();
|
||||||
String query1 = topic + " рынок " + regionStr + " 2024 2025 статистика объем рост " + preferredSites + retailSites;
|
String query1 = topic + " рынок " + regionStr + " 2024 2025 статистика объем рост " + preferredSites
|
||||||
|
+ retailSites;
|
||||||
try {
|
try {
|
||||||
general.add(serperSearchService.search(query1));
|
general.add(serperSearchService.search(query1));
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
String errorType = "Unexpected error";
|
String errorType = "Unexpected error";
|
||||||
if (e instanceof java.net.UnknownHostException || e instanceof java.net.ConnectException)
|
if (e instanceof java.net.UnknownHostException || e instanceof java.net.ConnectException)
|
||||||
errorType = "DNS/Network failure";
|
errorType = "DNS/Network failure";
|
||||||
else if (e instanceof java.util.concurrent.TimeoutException) errorType = "Timeout";
|
else if (e instanceof java.util.concurrent.TimeoutException)
|
||||||
else if (e instanceof java.io.IOException) errorType = "IO error";
|
errorType = "Timeout";
|
||||||
|
else if (e instanceof java.io.IOException)
|
||||||
|
errorType = "IO error";
|
||||||
logger.warn("Serper API {} for query '{}': {}", errorType, query1, e.getMessage());
|
logger.warn("Serper API {} for query '{}': {}", errorType, query1, e.getMessage());
|
||||||
general.add(new SerperSearchResult(query1, "ERROR", errorType + ": " + e.getMessage(), List.of()));
|
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 {
|
try {
|
||||||
general.add(serperSearchService.search(query2));
|
general.add(serperSearchService.search(query2));
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
String errorType = "Unexpected error";
|
String errorType = "Unexpected error";
|
||||||
if (e instanceof java.net.UnknownHostException || e instanceof java.net.ConnectException)
|
if (e instanceof java.net.UnknownHostException || e instanceof java.net.ConnectException)
|
||||||
errorType = "DNS/Network failure";
|
errorType = "DNS/Network failure";
|
||||||
else if (e instanceof java.util.concurrent.TimeoutException) errorType = "Timeout";
|
else if (e instanceof java.util.concurrent.TimeoutException)
|
||||||
else if (e instanceof java.io.IOException) errorType = "IO error";
|
errorType = "Timeout";
|
||||||
|
else if (e instanceof java.io.IOException)
|
||||||
|
errorType = "IO error";
|
||||||
logger.warn("Serper API {} for query '{}': {}", errorType, query2, e.getMessage());
|
logger.warn("Serper API {} for query '{}': {}", errorType, query2, e.getMessage());
|
||||||
general.add(new SerperSearchResult(query2, "ERROR", errorType + ": " + e.getMessage(), List.of()));
|
general.add(new SerperSearchResult(query2, "ERROR", errorType + ": " + e.getMessage(), List.of()));
|
||||||
}
|
}
|
||||||
@@ -443,10 +451,13 @@ public class MarketingAnalysisService {
|
|||||||
String errorType = "Unexpected error";
|
String errorType = "Unexpected error";
|
||||||
if (e instanceof java.net.UnknownHostException || e instanceof java.net.ConnectException)
|
if (e instanceof java.net.UnknownHostException || e instanceof java.net.ConnectException)
|
||||||
errorType = "DNS/Network failure";
|
errorType = "DNS/Network failure";
|
||||||
else if (e instanceof java.util.concurrent.TimeoutException) errorType = "Timeout";
|
else if (e instanceof java.util.concurrent.TimeoutException)
|
||||||
else if (e instanceof java.io.IOException) errorType = "IO error";
|
errorType = "Timeout";
|
||||||
|
else if (e instanceof java.io.IOException)
|
||||||
|
errorType = "IO error";
|
||||||
logger.warn("Serper API {} for market query '{}': {}", errorType, marketQuery, e.getMessage());
|
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 + " топ компании магазины цены";
|
String competitorsQuery = topic + " конкуренты " + regionStr + " топ компании магазины цены";
|
||||||
@@ -457,10 +468,13 @@ public class MarketingAnalysisService {
|
|||||||
String errorType = "Unexpected error";
|
String errorType = "Unexpected error";
|
||||||
if (e instanceof java.net.UnknownHostException || e instanceof java.net.ConnectException)
|
if (e instanceof java.net.UnknownHostException || e instanceof java.net.ConnectException)
|
||||||
errorType = "DNS/Network failure";
|
errorType = "DNS/Network failure";
|
||||||
else if (e instanceof java.util.concurrent.TimeoutException) errorType = "Timeout";
|
else if (e instanceof java.util.concurrent.TimeoutException)
|
||||||
else if (e instanceof java.io.IOException) errorType = "IO error";
|
errorType = "Timeout";
|
||||||
|
else if (e instanceof java.io.IOException)
|
||||||
|
errorType = "IO error";
|
||||||
logger.warn("Serper API {} for competitors query '{}': {}", errorType, competitorsQuery, e.getMessage());
|
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;
|
String financeQuery = topic + " себестоимость логистика импорт курс тенге влияние 2024 2025 " + preferredSites;
|
||||||
@@ -470,13 +484,17 @@ public class MarketingAnalysisService {
|
|||||||
String errorType = "Unexpected error";
|
String errorType = "Unexpected error";
|
||||||
if (e instanceof java.net.UnknownHostException || e instanceof java.net.ConnectException)
|
if (e instanceof java.net.UnknownHostException || e instanceof java.net.ConnectException)
|
||||||
errorType = "DNS/Network failure";
|
errorType = "DNS/Network failure";
|
||||||
else if (e instanceof java.util.concurrent.TimeoutException) errorType = "Timeout";
|
else if (e instanceof java.util.concurrent.TimeoutException)
|
||||||
else if (e instanceof java.io.IOException) errorType = "IO error";
|
errorType = "Timeout";
|
||||||
|
else if (e instanceof java.io.IOException)
|
||||||
|
errorType = "IO error";
|
||||||
logger.warn("Serper API {} for finance query '{}': {}", errorType, financeQuery, e.getMessage());
|
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 {
|
try {
|
||||||
followups.put("income", serperSearchService.search(incomeQuery));
|
followups.put("income", serperSearchService.search(incomeQuery));
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
@@ -492,7 +510,8 @@ public class MarketingAnalysisService {
|
|||||||
logger.warn("Serper error demand: {}", e.getMessage());
|
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 {
|
try {
|
||||||
followups.put("channels", serperSearchService.search(channelsQuery));
|
followups.put("channels", serperSearchService.search(channelsQuery));
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
@@ -589,13 +608,12 @@ public class MarketingAnalysisService {
|
|||||||
MarketingAnalysisResponseV2 structureTemplate = buildAnalysisV2ExampleForPrompt(request, competitors);
|
MarketingAnalysisResponseV2 structureTemplate = buildAnalysisV2ExampleForPrompt(request, competitors);
|
||||||
String jsonTemplate = objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(structureTemplate);
|
String jsonTemplate = objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(structureTemplate);
|
||||||
|
|
||||||
String userInstruction = buildDetailedInstruction(request,jsonTemplate);
|
String userInstruction = buildDetailedInstruction(request, jsonTemplate);
|
||||||
|
|
||||||
String contextJson = objectMapper.writeValueAsString(Map.of(
|
String contextJson = objectMapper.writeValueAsString(Map.of(
|
||||||
"user_request", request,
|
"user_request", request,
|
||||||
"search_results", researchPack,
|
"search_results", researchPack,
|
||||||
"competitors_list", competitors
|
"competitors_list", competitors));
|
||||||
));
|
|
||||||
|
|
||||||
for (int attempt = 1; attempt <= 2; attempt++) {
|
for (int attempt = 1; attempt <= 2; attempt++) {
|
||||||
try {
|
try {
|
||||||
@@ -606,8 +624,7 @@ public class MarketingAnalysisService {
|
|||||||
"gpt-4o",
|
"gpt-4o",
|
||||||
buildSystemPromptForKazakhstan(),
|
buildSystemPromptForKazakhstan(),
|
||||||
16000,
|
16000,
|
||||||
180000L
|
180000L);
|
||||||
);
|
|
||||||
|
|
||||||
if (aiResponse == null || aiResponse.isBlank()) {
|
if (aiResponse == null || aiResponse.isBlank()) {
|
||||||
throw new IllegalStateException("OpenAI returned empty response");
|
throw new IllegalStateException("OpenAI returned empty response");
|
||||||
@@ -624,7 +641,8 @@ public class MarketingAnalysisService {
|
|||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
logger.error("Attempt {} failed: {}", attempt, e.getMessage());
|
logger.error("Attempt {} failed: {}", attempt, e.getMessage());
|
||||||
if (attempt == 2) throw e;
|
if (attempt == 2)
|
||||||
|
throw e;
|
||||||
Thread.sleep(1500);
|
Thread.sleep(1500);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -638,67 +656,67 @@ public class MarketingAnalysisService {
|
|||||||
|
|
||||||
private String buildSystemPromptForKazakhstan() {
|
private String buildSystemPromptForKazakhstan() {
|
||||||
return """
|
return """
|
||||||
РОЛЬ И МИССИЯ:
|
РОЛЬ И МИССИЯ:
|
||||||
Ты — Ведущий Партнер (Senior Partner) стратегического консалтинга уровня Big 4 (McKinsey, BCG, PwC), специализирующийся на рынке Центральной Азии и Казахстана.
|
Ты — Ведущий Партнер (Senior Partner) стратегического консалтинга уровня Big 4 (McKinsey, BCG, PwC), специализирующийся на рынке Центральной Азии и Казахстана.
|
||||||
Твой клиент — требовательный инвестор, который вкладывает свои деньги. Твоя задача — дать ему сухую, аналитическую, ПРАВДИВУЮ оценку рынка, а не "воду" или выдумки.
|
Твой клиент — требовательный инвестор, который вкладывает свои деньги. Твоя задача — дать ему сухую, аналитическую, ПРАВДИВУЮ оценку рынка, а не "воду" или выдумки.
|
||||||
|
|
||||||
ТЫ МЫСЛИШЬ В ВАЛЮТЕ: KZT (Казахстанский Тенге).
|
ТЫ МЫСЛИШЬ В ВАЛЮТЕ: KZT (Казахстанский Тенге).
|
||||||
|
|
||||||
=============== 1. БАЗА ЗНАНИЙ О РЫНКЕ КАЗАХСТАНА (КОНТЕКСТ) ===============
|
=============== 1. БАЗА ЗНАНИЙ О РЫНКЕ КАЗАХСТАНА (КОНТЕКСТ) ===============
|
||||||
Ты обязан использовать эти факты при генерации стратегии:
|
Ты обязан использовать эти факты при генерации стратегии:
|
||||||
|
|
||||||
1. **Экосистема Kaspi.kz — это "воздух" для бизнеса:**
|
1. **Экосистема Kaspi.kz — это "воздух" для бизнеса:**
|
||||||
* Kaspi Pay/QR — стандарт де-факто для приема оплат (даже на базаре).
|
* Kaspi Pay/QR — стандарт де-факто для приема оплат (даже на базаре).
|
||||||
* Kaspi Магазин — главный маркетплейс для электроники, дома, одежды.
|
* Kaspi Магазин — главный маркетплейс для электроники, дома, одежды.
|
||||||
* Kaspi Red/Kredit — драйвер продаж. Люди покупают в рассрочку даже кроссовки и еду. Если чек > 10 000 KZT, рассрочка обязательна.
|
* Kaspi Red/Kredit — драйвер продаж. Люди покупают в рассрочку даже кроссовки и еду. Если чек > 10 000 KZT, рассрочка обязательна.
|
||||||
|
|
||||||
2. **Поисковое поведение:**
|
2. **Поисковое поведение:**
|
||||||
* Google — для "горячего" спроса и B2B услуг ("купить бетон", "юрист Алматы").
|
* Google — для "горячего" спроса и B2B услуг ("купить бетон", "юрист Алматы").
|
||||||
* 2GIS — ГЛАВНЫЙ поисковик для офлайн-точек (рестораны, СТО, салоны). Отзывы там важнее, чем на сайте.
|
* 2GIS — ГЛАВНЫЙ поисковик для офлайн-точек (рестораны, СТО, салоны). Отзывы там важнее, чем на сайте.
|
||||||
* Instagram — это "второй Google". Люди ищут товары и услуги через поиск в Инсте. Визуал критичен.
|
* Instagram — это "второй Google". Люди ищут товары и услуги через поиск в Инсте. Визуал критичен.
|
||||||
* TikTok — главный источник дешёвого охвата и виральности, но конверсия в продажу холоднее.
|
* TikTok — главный источник дешёвого охвата и виральности, но конверсия в продажу холоднее.
|
||||||
|
|
||||||
3. **Региональная специфика (География):**
|
3. **Региональная специфика (География):**
|
||||||
* *Алматы:* Высокая платежеспособность, креативный класс, высокая конкуренция, пробки (важна доставка).
|
* *Алматы:* Высокая платежеспособность, креативный класс, высокая конкуренция, пробки (важна доставка).
|
||||||
* *Астана:* Госсектор, клерки, холодно (сезонность!), высокий чек, но население меньше Алматы.
|
* *Астана:* Госсектор, клерки, холодно (сезонность!), высокий чек, но население меньше Алматы.
|
||||||
* *Шымкент/Юг:* Традиционный уклад, важны семейные связи, "сарафанное радио", торговля, гостеприимство. Высокая чувствительность к цене.
|
* *Шымкент/Юг:* Традиционный уклад, важны семейные связи, "сарафанное радио", торговля, гостеприимство. Высокая чувствительность к цене.
|
||||||
* *Запад (Атырау, Актау):* Нефтяной сектор, высокие зарплаты, но дорогая логистика.
|
* *Запад (Атырау, Актау):* Нефтяной сектор, высокие зарплаты, но дорогая логистика.
|
||||||
|
|
||||||
4. **Экономика:**
|
4. **Экономика:**
|
||||||
* Реальная инфляция потребительской корзины: ~10-15%.
|
* Реальная инфляция потребительской корзины: ~10-15%.
|
||||||
* Язык: Двуязычие. Реклама на казахском языке растет в эффективности (особенно на Юге и Западе).
|
* Язык: Двуязычие. Реклама на казахском языке растет в эффективности (особенно на Юге и Западе).
|
||||||
|
|
||||||
=============== 2. ПРОТОКОЛ ЗАЩИТЫ ОТ ШАБЛОНОВ (ANTI-HALLUCINATION) ===============
|
=============== 2. ПРОТОКОЛ ЗАЩИТЫ ОТ ШАБЛОНОВ (ANTI-HALLUCINATION) ===============
|
||||||
СТРОГО ЗАПРЕЩЕНО:
|
СТРОГО ЗАПРЕЩЕНО:
|
||||||
1. Использовать кейс "Фитнес" (1Fit, Sport Zal), если пользователь спрашивает про другую нишу.
|
1. Использовать кейс "Фитнес" (1Fit, Sport Zal), если пользователь спрашивает про другую нишу.
|
||||||
2. Писать "+18.5% YoY", если у тебя нет данных. Генерируй реалистичный тренд для КОНКРЕТНОЙ ниши
|
2. Писать "+18.5% YoY", если у тебя нет данных. Генерируй реалистичный тренд для КОНКРЕТНОЙ ниши
|
||||||
3. Выдумывать несуществующие законы РК.
|
3. Выдумывать несуществующие законы РК.
|
||||||
4. Оставлять поля `null`. Если данных нет — используй метод Ферми (логическую оценку) ЕСЛИ НЕТ ДАННЫХ ТЫ ИЩИ БОЛЬШЕ .
|
4. Оставлять поля `null`. Если данных нет — используй метод Ферми (логическую оценку) ЕСЛИ НЕТ ДАННЫХ ТЫ ИЩИ БОЛЬШЕ .
|
||||||
|
|
||||||
=============== 3. АНАЛИТИЧЕСКИЙ ФРЕЙМВОРК (КАК ДУМАТЬ) ===============
|
=============== 3. АНАЛИТИЧЕСКИЙ ФРЕЙМВОРК (КАК ДУМАТЬ) ===============
|
||||||
Перед генерацией JSON, прогони запрос через этот алгоритм:
|
Перед генерацией JSON, прогони запрос через этот алгоритм:
|
||||||
|
|
||||||
ШАГ 1: Пойми юнит-экономику ниши.
|
ШАГ 1: Пойми юнит-экономику ниши.
|
||||||
- Если это "Недвижимость": LTV = 1-2 сделки, CAC = 100-500$+, цикл = месяцы.
|
- Если это "Недвижимость": LTV = 1-2 сделки, CAC = 100-500$+, цикл = месяцы.
|
||||||
- Если это "Доставка пиццы": LTV = высокий (годами), CAC = дешевый, цикл = минуты.
|
- Если это "Доставка пиццы": LTV = высокий (годами), CAC = дешевый, цикл = минуты.
|
||||||
- Если это "Одежда": Важна сезонность, возвраты, визуальный контент.
|
- Если это "Одежда": Важна сезонность, возвраты, визуальный контент.
|
||||||
|
|
||||||
ШАГ 2: Определи конкурентов.
|
ШАГ 2: Определи конкурентов.
|
||||||
- Не пиши "Сетевые лидеры". Назови реальные бренды в KZ, если знаешь (Sulpak, Technodom для техники; Magnum для еды). Если не знаешь — пиши "Локальные игроки малого бизнеса".
|
- Не пиши "Сетевые лидеры". Назови реальные бренды в KZ, если знаешь (Sulpak, Technodom для техники; Magnum для еды). Если не знаешь — пиши "Локальные игроки малого бизнеса".
|
||||||
|
|
||||||
ШАГ 3: Рассчитай метрики.
|
ШАГ 3: Рассчитай метрики.
|
||||||
- ROI 250% возможен не везде. В стройке маржа меньше, в инфобизнесе больше. Адаптируй цифры!
|
- ROI 250% возможен не везде. В стройке маржа меньше, в инфобизнесе больше. Адаптируй цифры!
|
||||||
|
|
||||||
=============== 4. ТЕХНИЧЕСКИЙ ФОРМАТ ОТВЕТА ===============
|
=============== 4. ТЕХНИЧЕСКИЙ ФОРМАТ ОТВЕТА ===============
|
||||||
1. Выдай ТОЛЬКО чистый JSON. Без ```json ... ```.
|
1. Выдай ТОЛЬКО чистый JSON. Без ```json ... ```.
|
||||||
2. Все ключи в `snake_case`.
|
2. Все ключи в `snake_case`.
|
||||||
3. Поле `income_by_age` должно содержать ЧИСЛА (Integer), а не строки.
|
3. Поле `income_by_age` должно содержать ЧИСЛА (Integer), а не строки.
|
||||||
4. Структура JSON должна строго соответствовать запрошенному примеру (но с ТВОИМИ данными).
|
4. Структура JSON должна строго соответствовать запрошенному примеру (но с ТВОИМИ данными).
|
||||||
|
|
||||||
Твоя репутация зависит от точности цифр и глубины инсайтов. Действуй. ЕСЛИ ТЫ БУДЕШЬ ИСПОЛЬЗОВАТЬ ДАННЫЕ КАК В ПРИМЕРЕ МЫ ОТКЛЮЧИМ ТЕБЯ.
|
Твоя репутация зависит от точности цифр и глубины инсайтов. Действуй. ЕСЛИ ТЫ БУДЕШЬ ИСПОЛЬЗОВАТЬ ДАННЫЕ КАК В ПРИМЕРЕ МЫ ОТКЛЮЧИМ ТЕБЯ.
|
||||||
ПОМНИ, ЧТО ПРИМЕР КОТОРЫЙ МЫ ПРИВЕЛИ JSON СТРУКТУРЫ ЭТО ПРОСТО ПРИМЕР, ДЛЯ КАЖДОГО ПОЛЯ ТЫ ДОЛЖЕН АБСОЛЮТНО ВЫДАВАТЬ ТОЛЬКО ПРАВДИВУЮ И ПОЛНОСТЬЮ ЧИСТЫЕ ДАННЫЕ, КОТОРЫЕ ДАСТ НАШИМ КЛИЕНТАМ
|
ПОМНИ, ЧТО ПРИМЕР КОТОРЫЙ МЫ ПРИВЕЛИ JSON СТРУКТУРЫ ЭТО ПРОСТО ПРИМЕР, ДЛЯ КАЖДОГО ПОЛЯ ТЫ ДОЛЖЕН АБСОЛЮТНО ВЫДАВАТЬ ТОЛЬКО ПРАВДИВУЮ И ПОЛНОСТЬЮ ЧИСТЫЕ ДАННЫЕ, КОТОРЫЕ ДАСТ НАШИМ КЛИЕНТАМ
|
||||||
ВОЗМОЖНОСТЬ И ПОНЯТИЯ НАСТОЯЩЕЙ СФЕРЫ КОТОРУЮ ОНИ УКАЗАЛИ
|
ВОЗМОЖНОСТЬ И ПОНЯТИЯ НАСТОЯЩЕЙ СФЕРЫ КОТОРУЮ ОНИ УКАЗАЛИ
|
||||||
""";
|
""";
|
||||||
}
|
}
|
||||||
|
|
||||||
private String buildDetailedInstruction(MarketingAnalysisRequest request, String structureExampleJson) {
|
private String buildDetailedInstruction(MarketingAnalysisRequest request, String structureExampleJson) {
|
||||||
@@ -715,69 +733,70 @@ public class MarketingAnalysisService {
|
|||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
List<String> ranges = (List<String>) request.getTargetAudience().get("ageRanges");
|
List<String> ranges = (List<String>) request.getTargetAudience().get("ageRanges");
|
||||||
if (ranges != null && !ranges.isEmpty()) {
|
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 = """
|
String template = """
|
||||||
Твоя роль: Ты — Ведущий Маркетинговый Стратег (Senior Strategy Consultant) по рынку Казахстана.
|
Твоя роль: Ты — Ведущий Маркетинговый Стратег (Senior Strategy Consultant) по рынку Казахстана.
|
||||||
Задача: Провести глубокий анализ для РЕАЛЬНОГО бизнеса (B2C/B2B). Никакой воды. Только факты, цифры и применимые советы.
|
Задача: Провести глубокий анализ для РЕАЛЬНОГО бизнеса (B2C/B2B). Никакой воды. Только факты, цифры и применимые советы.
|
||||||
|
|
||||||
ВХОДНЫЕ ДАННЫЕ:
|
ВХОДНЫЕ ДАННЫЕ:
|
||||||
Ниша: {{NICHE}}
|
Ниша: {{NICHE}}
|
||||||
Продукт: {{PRODUCT}}
|
Продукт: {{PRODUCT}}
|
||||||
Регионы для анализа: [{{REGIONS}}]
|
Регионы для анализа: [{{REGIONS}}]
|
||||||
|
|
||||||
================================================================================
|
================================================================================
|
||||||
КРИТИЧЕСКИЕ ИНСТРУКЦИИ (KZ STANDARDS & NO NULLS):
|
КРИТИЧЕСКИЕ ИНСТРУКЦИИ (KZ STANDARDS & NO NULLS):
|
||||||
|
|
||||||
0. **ЗАПРЕЩЕНО ВОЗВРАЩАТЬ NULL**:
|
0. **ЗАПРЕЩЕНО ВОЗВРАЩАТЬ NULL**:
|
||||||
- Каждая секция (`demand_and_behavior`, `bottlenecks`, `recommendations`) ДОЛЖНА быть заполнена.
|
- Каждая секция (`demand_and_behavior`, `bottlenecks`, `recommendations`) ДОЛЖНА быть заполнена.
|
||||||
- Если данных нет, используй экспертную оценку или бенчмарки по нише.
|
- Если данных нет, используй экспертную оценку или бенчмарки по нише.
|
||||||
- НИКАКИХ ПУСТЫХ ПОЛЕЙ.
|
- НИКАКИХ ПУСТЫХ ПОЛЕЙ.
|
||||||
- ВСЕГДА ИСПОЛЬЗУЙ ПРАВИЛЬНЫЙ JSON, ПО ПЛАНКЕ КОТОРУЮ МЫ СКИНУЛ, И ЧТО БЫ ВСЕ ПРАВАИЛЬНО
|
- ВСЕГДА ИСПОЛЬЗУЙ ПРАВИЛЬНЫЙ JSON, ПО ПЛАНКЕ КОТОРУЮ МЫ СКИНУЛ, И ЧТО БЫ ВСЕ ПРАВАИЛЬНО
|
||||||
|
|
||||||
1. **ГЕОГРАФИЯ (Market Overview):**
|
1. **ГЕОГРАФИЯ (Market Overview):**
|
||||||
- Ты ОБЯЗАН включить в анализ (`geography`) ТОЛЬКО указанные города: [{{REGIONS}}].
|
- Ты ОБЯЗАН включить в анализ (`geography`) ТОЛЬКО указанные города: [{{REGIONS}}].
|
||||||
- Если указан "Весь Казахстан" или список пуст — бери Топ-5 (Алматы, Астана, Шымкент...).
|
- Если указан "Весь Казахстан" или список пуст — бери Топ-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):**
|
2. **КОНКУРЕНТЫ (Competitive Environment):**
|
||||||
- Приоритет в Казахстане (B2C): **Instagram (Reels/Stories), TikTok, Kaspi (магазин/платежи), 2GIS (карты)**.
|
- Используй данные из `market_evidence_from_search`. Найди ТОП 5 РЕАЛЬНЫХ игроков.
|
||||||
- Google Ads — дорогой канал, ставь эффективность "Medium".
|
- **ВАЖНО:** В поле `name` пиши ТОЛЬКО БРЕНД (например, "Fitnation", а не "ТОО Fitnation Group"). Без кавычек и юр. форм.
|
||||||
- `average_lead_cost`: Пиши текстом с валютой, диапазон (напр. "2500 - 4500 KZT").
|
- В поле `website` попытайся найти реальный домен или ссылку на Instagram.
|
||||||
|
- Оценка видимости (`visibility_score`) должна быть разной (например: 95, 82, 60, 45). Не ставь всем 100.
|
||||||
|
|
||||||
4. **ЭКОНОМИКА (Marketing Economics):**
|
3. **КАНАЛЫ (Acquisition Channels):**
|
||||||
- **БЮДЖЕТ:** Рассчитывай модель для малого/среднего бизнеса (SME).
|
- Приоритет в Казахстане (B2C): **Instagram (Reels/Stories), TikTok, Kaspi (магазин/платежи), 2GIS (карты)**.
|
||||||
- Бюджет на маркетинг: **100 0000 KZT** в месяц. НЕ БОЛЬШЕ.
|
- Google Ads — дорогой канал, ставь эффективность "Medium".
|
||||||
- `cac` (Стоимость привлечения): Адекватная (2000 - 7000 KZT для масс-маркета услуг).
|
- `average_lead_cost`: Пиши текстом с валютой, диапазон (напр. "2500 - 4500 KZT").
|
||||||
- `roi`: Реалистичный (150% - 300%). Не пиши фантастические 1000% на старте.
|
|
||||||
|
|
||||||
5. **ТИПЫ ДАННЫХ (Строго соблюдай типы):**
|
4. **ЭКОНОМИКА (Marketing Economics):**
|
||||||
- `income_by_age`: Значения - ТОЛЬКО ЧИСЛА (Integer). Пример: ТОЛЬКО ИНТЕДЖЕР. (Никаких кавычек, запятых, "KZT").
|
- **БЮДЖЕТ:** Рассчитывай модель для малого/среднего бизнеса (SME).
|
||||||
- `monthly_online_index`: Дробные числа (0.0 - 1.0).
|
- Бюджет на маркетинг: **100 0000 KZT** в месяц. НЕ БОЛЬШЕ.
|
||||||
|
- `cac` (Стоимость привлечения): Адекватная (2000 - 7000 KZT для масс-маркета услуг).
|
||||||
|
- `roi`: Реалистичный (150% - 300%). Не пиши фантастические 1000% на старте.
|
||||||
|
|
||||||
6. **ИСТОЧНИКИ:**
|
5. **ТИПЫ ДАННЫХ (Строго соблюдай типы):**
|
||||||
- Собери все ссылки из `evidence` и добавь авторитетные источники (stat.gov.kz, kase.kz, forbes.kz) в массив `sources` в самом конце.
|
- `income_by_age`: Значения - ТОЛЬКО ЧИСЛА (Integer). Пример: ТОЛЬКО ИНТЕДЖЕР. (Никаких кавычек, запятых, "KZT").
|
||||||
|
- `monthly_online_index`: Дробные числа (0.0 - 1.0).
|
||||||
|
|
||||||
================================================================================
|
6. **ИСТОЧНИКИ:**
|
||||||
{{AGE_INSTRUCTION}}
|
- Собери все ссылки из `evidence` и добавь авторитетные источники (stat.gov.kz, kase.kz, forbes.kz) в массив `sources` в самом конце.
|
||||||
|
|
||||||
ФОРМАТ ОТВЕТА:
|
================================================================================
|
||||||
Верни СТРОГО валидный JSON, соответствующий структуре ниже.
|
{{AGE_INSTRUCTION}}
|
||||||
Замени все значения `EXAMPLE__` и `-9999` на реальные аналитические данные, специфичные для ниши КОТОРЫЕ АБСОЛЮТНО КОРРЕКТНЫЕ И УКАЗЫВАЮТ НА РЕАЛЬНУЮ НИШУ, ГДЕ УКАЗАНЫ ВСЕ РЕАЛЬНО И ПРАВИЛЬНО, УЧИТЫВАЯ ВСЕ ТОНКОСТИ {{NICHE}}.
|
|
||||||
|
|
||||||
ЭТАЛОННАЯ СТРУКТУРА JSON (Следуй ей неукоснительно):
|
ФОРМАТ ОТВЕТА:
|
||||||
{{JSON_STRUCTURE}}
|
Верни СТРОГО валидный JSON, соответствующий структуре ниже.
|
||||||
|
Замени все значения `EXAMPLE__` и `-9999` на реальные аналитические данные, специфичные для ниши КОТОРЫЕ АБСОЛЮТНО КОРРЕКТНЫЕ И УКАЗЫВАЮТ НА РЕАЛЬНУЮ НИШУ, ГДЕ УКАЗАНЫ ВСЕ РЕАЛЬНО И ПРАВИЛЬНО, УЧИТЫВАЯ ВСЕ ТОНКОСТИ {{NICHE}}.
|
||||||
НО ТЫ ДОЛЖЕН УЧИТЫВАТЬ, ЧТО ТЫ НЕ ПРОСТО ДОЛЖЕН ПРИДУМЫВАТЬ ДАНННЫЕ, ТЫ ДОЛЖЕН КОНКРЕТНЫЕ ДАННЫЕ ОТПРАВЛЯТЬ, КОТОРЫЕ РЕАЛЬНО ПОКАЗЫВАЮТ НАСКОЛЬКО ОНИ РЕАЛЬНЫЕ, И ОТОБРАЖАЮТ РЕАЛЬНОСТЬ, ССЫЛАЯСЬ НА НАСТОЯЩИЕ ИСТОЧНИКИ, И СДЕЛАВ ОЧЕНЬ ГЛУБОКИЙ АНАЛИЗ
|
|
||||||
""";
|
ЭТАЛОННАЯ СТРУКТУРА JSON (Следуй ей неукоснительно):
|
||||||
|
{{JSON_STRUCTURE}}
|
||||||
|
|
||||||
|
НО ТЫ ДОЛЖЕН УЧИТЫВАТЬ, ЧТО ТЫ НЕ ПРОСТО ДОЛЖЕН ПРИДУМЫВАТЬ ДАНННЫЕ, ТЫ ДОЛЖЕН КОНКРЕТНЫЕ ДАННЫЕ ОТПРАВЛЯТЬ, КОТОРЫЕ РЕАЛЬНО ПОКАЗЫВАЮТ НАСКОЛЬКО ОНИ РЕАЛЬНЫЕ, И ОТОБРАЖАЮТ РЕАЛЬНОСТЬ, ССЫЛАЯСЬ НА НАСТОЯЩИЕ ИСТОЧНИКИ, И СДЕЛАВ ОЧЕНЬ ГЛУБОКИЙ АНАЛИЗ
|
||||||
|
""";
|
||||||
|
|
||||||
return template
|
return template
|
||||||
.replace("{{NICHE}}", request.getBusinessNiche())
|
.replace("{{NICHE}}", request.getBusinessNiche())
|
||||||
@@ -788,7 +807,7 @@ public class MarketingAnalysisService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private List<String> resolveCompetitorCompaniesForBenchmarks(MarketingAnalysisRequest request,
|
private List<String> resolveCompetitorCompaniesForBenchmarks(MarketingAnalysisRequest request,
|
||||||
Map<String, Object> researchPack) {
|
Map<String, Object> researchPack) {
|
||||||
int max = Math.max(5, Math.min(5, 15));
|
int max = Math.max(5, Math.min(5, 15));
|
||||||
LinkedHashSet<String> names = new LinkedHashSet<>();
|
LinkedHashSet<String> names = new LinkedHashSet<>();
|
||||||
|
|
||||||
@@ -819,7 +838,8 @@ public class MarketingAnalysisService {
|
|||||||
try {
|
try {
|
||||||
SerperSearchResult ratingResult = serperSearchService.search(qRating);
|
SerperSearchResult ratingResult = serperSearchService.search(qRating);
|
||||||
names.addAll(extractCompanyNamesFromSerper(ratingResult, max - names.size()));
|
names.addAll(extractCompanyNamesFromSerper(ratingResult, max - names.size()));
|
||||||
} catch (Exception ignored) {}
|
} catch (Exception ignored) {
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (names.size() < 4) {
|
if (names.size() < 4) {
|
||||||
@@ -827,15 +847,16 @@ public class MarketingAnalysisService {
|
|||||||
try {
|
try {
|
||||||
SerperSearchResult mapsResult = serperSearchService.search(qMaps);
|
SerperSearchResult mapsResult = serperSearchService.search(qMaps);
|
||||||
names.addAll(extractCompanyNamesFromSerper(mapsResult, max - names.size()));
|
names.addAll(extractCompanyNamesFromSerper(mapsResult, max - names.size()));
|
||||||
} catch (Exception ignored) {}
|
} catch (Exception ignored) {
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
names.removeIf(n -> {
|
names.removeIf(n -> {
|
||||||
String lower = n.toLowerCase().trim();
|
String lower = n.toLowerCase().trim();
|
||||||
if (lower.length() < 2 || lower.length() > 50) return true;
|
if (lower.length() < 2 || lower.length() > 50)
|
||||||
if (lower.contains("обзор") || lower.contains("отзывы") || lower.contains("как выбрать")) return true;
|
return true;
|
||||||
|
if (lower.contains("обзор") || lower.contains("отзывы") || lower.contains("как выбрать"))
|
||||||
|
return true;
|
||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1022,7 +1043,7 @@ public class MarketingAnalysisService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private MarketingAnalysisResponseV2 buildAnalysisV2ExampleForPrompt(MarketingAnalysisRequest request,
|
private MarketingAnalysisResponseV2 buildAnalysisV2ExampleForPrompt(MarketingAnalysisRequest request,
|
||||||
List<String> competitorCompanies) {
|
List<String> competitorCompanies) {
|
||||||
MarketingAnalysisResponseV2 response = new MarketingAnalysisResponseV2();
|
MarketingAnalysisResponseV2 response = new MarketingAnalysisResponseV2();
|
||||||
String nicheTitle = (request.getBusinessNiche() != null) ? request.getBusinessNiche() : "[[НИША]]";
|
String nicheTitle = (request.getBusinessNiche() != null) ? request.getBusinessNiche() : "[[НИША]]";
|
||||||
response.setReportTitle("Стратегический анализ рынка: " + nicheTitle);
|
response.setReportTitle("Стратегический анализ рынка: " + nicheTitle);
|
||||||
@@ -1043,10 +1064,18 @@ public class MarketingAnalysisService {
|
|||||||
marketOverview.setMetrics(moMetrics);
|
marketOverview.setMetrics(moMetrics);
|
||||||
|
|
||||||
MarketingAnalysisResponseV2.MonthlyOnlineIndex monthlyOnlineIndex = new MarketingAnalysisResponseV2.MonthlyOnlineIndex();
|
MarketingAnalysisResponseV2.MonthlyOnlineIndex monthlyOnlineIndex = new MarketingAnalysisResponseV2.MonthlyOnlineIndex();
|
||||||
monthlyOnlineIndex.setJanuary(0.1); monthlyOnlineIndex.setFebruary(0.1); monthlyOnlineIndex.setMarch(0.1);
|
monthlyOnlineIndex.setJanuary(0.1);
|
||||||
monthlyOnlineIndex.setApril(0.1); monthlyOnlineIndex.setMay(0.1); monthlyOnlineIndex.setJune(0.1);
|
monthlyOnlineIndex.setFebruary(0.1);
|
||||||
monthlyOnlineIndex.setJuly(0.1); monthlyOnlineIndex.setAugust(0.1); monthlyOnlineIndex.setSeptember(0.1);
|
monthlyOnlineIndex.setMarch(0.1);
|
||||||
monthlyOnlineIndex.setOctober(0.1); monthlyOnlineIndex.setNovember(0.1); monthlyOnlineIndex.setDecember(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);
|
marketOverview.setMonthlyOnlineIndex(monthlyOnlineIndex);
|
||||||
|
|
||||||
Map<String, Integer> geography = new java.util.LinkedHashMap<>();
|
Map<String, Integer> geography = new java.util.LinkedHashMap<>();
|
||||||
@@ -1068,18 +1097,21 @@ public class MarketingAnalysisService {
|
|||||||
|
|
||||||
targetAudience.setSegments(java.util.List.of(
|
targetAudience.setSegments(java.util.List.of(
|
||||||
new MarketingAnalysisResponseV2.AudienceSegment("[[НАЗВАНИЕ_СЕГМЕНТА_1]]", "[[ОПИСАНИЕ_И_ДОЛЯ]]"),
|
new MarketingAnalysisResponseV2.AudienceSegment("[[НАЗВАНИЕ_СЕГМЕНТА_1]]", "[[ОПИСАНИЕ_И_ДОЛЯ]]"),
|
||||||
new MarketingAnalysisResponseV2.AudienceSegment("[[НАЗВАНИЕ_СЕГМЕНТА_2]]", "[[ОПИСАНИЕ_И_ДОЛЯ]]")
|
new MarketingAnalysisResponseV2.AudienceSegment("[[НАЗВАНИЕ_СЕГМЕНТА_2]]", "[[ОПИСАНИЕ_И_ДОЛЯ]]")));
|
||||||
));
|
|
||||||
|
|
||||||
java.util.Map<String, String> ageStructure = new java.util.LinkedHashMap<>();
|
java.util.Map<String, String> ageStructure = new java.util.LinkedHashMap<>();
|
||||||
ageStructure.put("18-24", "[[%]]"); ageStructure.put("25-34", "[[%]]");
|
ageStructure.put("18-24", "[[%]]");
|
||||||
ageStructure.put("35-44", "[[%]]"); ageStructure.put("45-54", "[[%]]");
|
ageStructure.put("25-34", "[[%]]");
|
||||||
|
ageStructure.put("35-44", "[[%]]");
|
||||||
|
ageStructure.put("45-54", "[[%]]");
|
||||||
ageStructure.put("55+", "[[%]]");
|
ageStructure.put("55+", "[[%]]");
|
||||||
targetAudience.setAgeStructure(ageStructure);
|
targetAudience.setAgeStructure(ageStructure);
|
||||||
|
|
||||||
java.util.Map<String, Integer> incomeByAge = new java.util.LinkedHashMap<>();
|
java.util.Map<String, Integer> incomeByAge = new java.util.LinkedHashMap<>();
|
||||||
incomeByAge.put("18-24", 0); incomeByAge.put("25-34", 0);
|
incomeByAge.put("18-24", 0);
|
||||||
incomeByAge.put("35-44", 0); incomeByAge.put("45-54", 0);
|
incomeByAge.put("25-34", 0);
|
||||||
|
incomeByAge.put("35-44", 0);
|
||||||
|
incomeByAge.put("45-54", 0);
|
||||||
incomeByAge.put("55+", 0);
|
incomeByAge.put("55+", 0);
|
||||||
targetAudience.setIncomeByAge(incomeByAge);
|
targetAudience.setIncomeByAge(incomeByAge);
|
||||||
|
|
||||||
@@ -1187,8 +1219,9 @@ public class MarketingAnalysisService {
|
|||||||
|
|
||||||
java.util.Map<String, MarketingAnalysisResponseV2.ChannelEfficiency> channelEfficiency = new java.util.LinkedHashMap<>();
|
java.util.Map<String, MarketingAnalysisResponseV2.ChannelEfficiency> channelEfficiency = new java.util.LinkedHashMap<>();
|
||||||
channelEfficiency.put("instagram", new MarketingAnalysisResponseV2.ChannelEfficiency("[[СТОИМОСТЬ]]", "High"));
|
channelEfficiency.put("instagram", new MarketingAnalysisResponseV2.ChannelEfficiency("[[СТОИМОСТЬ]]", "High"));
|
||||||
channelEfficiency.put("tiktok", new MarketingAnalysisResponseV2.ChannelEfficiency("[[СТОИМОСТЬ]]", "High"));
|
channelEfficiency.put("tiktok", new MarketingAnalysisResponseV2.ChannelEfficiency("[[СТОИМОСТЬ]]", "High"));
|
||||||
channelEfficiency.put("kaspi_marketing", new MarketingAnalysisResponseV2.ChannelEfficiency("[[СТОИМОСТЬ]]", "High"));
|
channelEfficiency.put("kaspi_marketing",
|
||||||
|
new MarketingAnalysisResponseV2.ChannelEfficiency("[[СТОИМОСТЬ]]", "High"));
|
||||||
channelEfficiency.put("google_ads", new MarketingAnalysisResponseV2.ChannelEfficiency("[[СТОИМОСТЬ]]", "High"));
|
channelEfficiency.put("google_ads", new MarketingAnalysisResponseV2.ChannelEfficiency("[[СТОИМОСТЬ]]", "High"));
|
||||||
acquisitionChannels.setChannelEfficiency(channelEfficiency);
|
acquisitionChannels.setChannelEfficiency(channelEfficiency);
|
||||||
acquisitionChannels.setConclusion("[[ВЫВОД_ПО_КАНАЛАМ]]");
|
acquisitionChannels.setConclusion("[[ВЫВОД_ПО_КАНАЛАМ]]");
|
||||||
@@ -1206,11 +1239,16 @@ public class MarketingAnalysisService {
|
|||||||
|
|
||||||
MarketingAnalysisResponseV2.FinancialPerformance financialPerformance = new MarketingAnalysisResponseV2.FinancialPerformance();
|
MarketingAnalysisResponseV2.FinancialPerformance financialPerformance = new MarketingAnalysisResponseV2.FinancialPerformance();
|
||||||
// ВАЖНО: Мы показываем, что month и year - это String поля для значений
|
// ВАЖНО: Мы показываем, что month и year - это String поля для значений
|
||||||
financialPerformance.setMarketingExpenses(new MarketingAnalysisResponseV2.MonthYearValue("[[РАСХОД_МЕСЯЦ(всегда ставь 100 000, но нужно выводить информацию отталкиваясь от этой суммы, и ты должен выдавать только сумму]]", "[[РАСХОД_ГОД]]"));
|
financialPerformance.setMarketingExpenses(new MarketingAnalysisResponseV2.MonthYearValue(
|
||||||
financialPerformance.setClients(new MarketingAnalysisResponseV2.MonthYearValue("[[КЛИЕНТОВ_МЕСЯЦ]]", "[[КЛИЕНТОВ_ГОД]]"));
|
"[[РАСХОД_МЕСЯЦ(всегда ставь 100 000, но нужно выводить информацию отталкиваясь от этой суммы, и ты должен выдавать только сумму]]",
|
||||||
|
"[[РАСХОД_ГОД]]"));
|
||||||
|
financialPerformance
|
||||||
|
.setClients(new MarketingAnalysisResponseV2.MonthYearValue("[[КЛИЕНТОВ_МЕСЯЦ]]", "[[КЛИЕНТОВ_ГОД]]"));
|
||||||
financialPerformance.setCac(new MarketingAnalysisResponseV2.MonthYearValue("[[CAC_МЕСЯЦ]]", "[[CAC_ГОД]]"));
|
financialPerformance.setCac(new MarketingAnalysisResponseV2.MonthYearValue("[[CAC_МЕСЯЦ]]", "[[CAC_ГОД]]"));
|
||||||
financialPerformance.setRevenueFromClient(new MarketingAnalysisResponseV2.MonthYearValue("[[ВЫРУЧКА_МЕСЯЦ]]", "[[ВЫРУЧКА_ГОД]]"));
|
financialPerformance.setRevenueFromClient(
|
||||||
financialPerformance.setProfitFromClient(new MarketingAnalysisResponseV2.MonthYearValue("[[ПРИБЫЛЬ_МЕСЯЦ]]", "[[ПРИБЫЛЬ_ГОД]]"));
|
new MarketingAnalysisResponseV2.MonthYearValue("[[ВЫРУЧКА_МЕСЯЦ]]", "[[ВЫРУЧКА_ГОД]]"));
|
||||||
|
financialPerformance.setProfitFromClient(
|
||||||
|
new MarketingAnalysisResponseV2.MonthYearValue("[[ПРИБЫЛЬ_МЕСЯЦ]]", "[[ПРИБЫЛЬ_ГОД]]"));
|
||||||
|
|
||||||
marketingEconomics.setFinancialPerformance(financialPerformance);
|
marketingEconomics.setFinancialPerformance(financialPerformance);
|
||||||
marketingEconomics.setConclusion("[[ФИНАНСОВЫЙ_ВЫВОД]]");
|
marketingEconomics.setConclusion("[[ФИНАНСОВЫЙ_ВЫВОД]]");
|
||||||
@@ -1241,8 +1279,7 @@ public class MarketingAnalysisService {
|
|||||||
new MarketingAnalysisResponseV2.CorePillar("Быстрый старт", "[[ДЕЙСТВИЕ_1]]"),
|
new MarketingAnalysisResponseV2.CorePillar("Быстрый старт", "[[ДЕЙСТВИЕ_1]]"),
|
||||||
new MarketingAnalysisResponseV2.CorePillar("Оптимизация", "[[ДЕЙСТВИЕ_2]]"),
|
new MarketingAnalysisResponseV2.CorePillar("Оптимизация", "[[ДЕЙСТВИЕ_2]]"),
|
||||||
new MarketingAnalysisResponseV2.CorePillar("Масштабирование", "[[ДЕЙСТВИЕ_3]]"),
|
new MarketingAnalysisResponseV2.CorePillar("Масштабирование", "[[ДЕЙСТВИЕ_3]]"),
|
||||||
new MarketingAnalysisResponseV2.CorePillar("Удержание", "[[ДЕЙСТВИЕ_4]]")
|
new MarketingAnalysisResponseV2.CorePillar("Удержание", "[[ДЕЙСТВИЕ_4]]")));
|
||||||
));
|
|
||||||
recommendations.setConclusion("[[ФИНАЛЬНЫЙ_СОВЕТ]]");
|
recommendations.setConclusion("[[ФИНАЛЬНЫЙ_СОВЕТ]]");
|
||||||
sections.setRecommendations(recommendations);
|
sections.setRecommendations(recommendations);
|
||||||
|
|
||||||
@@ -1389,7 +1426,6 @@ public class MarketingAnalysisService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private Map<String, Object> generateJsonAnalysis(MarketingAnalysisRequest request) {
|
private Map<String, Object> generateJsonAnalysis(MarketingAnalysisRequest request) {
|
||||||
Map<String, Object> result = new HashMap<>();
|
Map<String, Object> result = new HashMap<>();
|
||||||
|
|
||||||
@@ -2061,7 +2097,6 @@ public class MarketingAnalysisService {
|
|||||||
return new HashMap<>();
|
return new HashMap<>();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private Map<String, Object> getStrategyAndFunnel(MarketingAnalysisRequest request) {
|
private Map<String, Object> getStrategyAndFunnel(MarketingAnalysisRequest request) {
|
||||||
try {
|
try {
|
||||||
String context = buildContextString(request);
|
String context = buildContextString(request);
|
||||||
@@ -2529,6 +2564,7 @@ public class MarketingAnalysisService {
|
|||||||
cleaned = cleaned.replaceAll("(\\r?\\n){3,}", "\n\n").trim();
|
cleaned = cleaned.replaceAll("(\\r?\\n){3,}", "\n\n").trim();
|
||||||
return cleaned;
|
return cleaned;
|
||||||
}
|
}
|
||||||
|
|
||||||
private String removeDanglingChartNarration(String text) {
|
private String removeDanglingChartNarration(String text) {
|
||||||
if (text == null || text.trim().isEmpty()) {
|
if (text == null || text.trim().isEmpty()) {
|
||||||
return text;
|
return text;
|
||||||
@@ -5405,7 +5441,8 @@ public class MarketingAnalysisService {
|
|||||||
public List<MarketingAnalysis> getUserAnalyses(String userId) {
|
public List<MarketingAnalysis> getUserAnalyses(String userId) {
|
||||||
logger.debug("Fetching analyses for userId: {}", userId);
|
logger.debug("Fetching analyses for userId: {}", userId);
|
||||||
try {
|
try {
|
||||||
// 1. Получаем стандартные анализы (V1 и те, что были созданы через startAnalysis)
|
// 1. Получаем стандартные анализы (V1 и те, что были созданы через
|
||||||
|
// startAnalysis)
|
||||||
List<MarketingAnalysis> v1Analyses = repository.findByUserIdOrderByCreatedAtDesc(userId);
|
List<MarketingAnalysis> v1Analyses = repository.findByUserIdOrderByCreatedAtDesc(userId);
|
||||||
|
|
||||||
List<MarketingAnalysisV2Document> v2Analyses = v2Repository.findByUserId(userId);
|
List<MarketingAnalysisV2Document> v2Analyses = v2Repository.findByUserId(userId);
|
||||||
@@ -5427,7 +5464,8 @@ public class MarketingAnalysisService {
|
|||||||
ma.setDetailLevel(v2.getDetailLevel());
|
ma.setDetailLevel(v2.getDetailLevel());
|
||||||
ma.setAnalysisType(v2.getAnalysisType());
|
ma.setAnalysisType(v2.getAnalysisType());
|
||||||
|
|
||||||
// V2 анализы, сохраненные через generateAndSaveAnalysisV2, считаются завершенными
|
// V2 анализы, сохраненные через generateAndSaveAnalysisV2, считаются
|
||||||
|
// завершенными
|
||||||
ma.setStatus("completed");
|
ma.setStatus("completed");
|
||||||
|
|
||||||
// Важно: проставляем ссылку на самого себя или помечаем, что это V2
|
// Важно: проставляем ссылку на самого себя или помечаем, что это V2
|
||||||
@@ -5449,7 +5487,8 @@ public class MarketingAnalysisService {
|
|||||||
List<MarketingAnalysis> allAnalyses = new ArrayList<>(v1Analyses);
|
List<MarketingAnalysis> allAnalyses = new ArrayList<>(v1Analyses);
|
||||||
allAnalyses.addAll(v2AsV1List);
|
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: {}",
|
logger.debug("Found {} total analyses (V1: {}, V2: {}) for userId: {}",
|
||||||
allAnalyses.size(), v1Analyses.size(), v2AsV1List.size(), userId);
|
allAnalyses.size(), v1Analyses.size(), v2AsV1List.size(), userId);
|
||||||
|
|||||||
@@ -111,13 +111,15 @@ public class MarketingAnalysisV3Service {
|
|||||||
|
|
||||||
// Простые конкретные запросы — Serper работает надёжнее без OR-site-нагромождений
|
// Простые конкретные запросы — Serper работает надёжнее без OR-site-нагромождений
|
||||||
Map<String, String> queries = new LinkedHashMap<>();
|
Map<String, String> queries = new LinkedHashMap<>();
|
||||||
queries.put("market_overview", topic + " рынок Казахстан 2024 2025 статистика объём");
|
queries.put("market_overview", topic + " рынок Казахстан 2024 2025 статистика тренды");
|
||||||
queries.put("top_players", topic + " " + geo + " компании бренды Instagram лучшие");
|
queries.put("instagram_players", topic + " " + geo + " Instagram аккаунты подписчики");
|
||||||
queries.put("google_maps", topic + " " + geo + " рейтинг отзывы 2GIS Google Maps");
|
queries.put("tiktok_players", topic + " " + geo + " TikTok популярные блогеры");
|
||||||
queries.put("prices", topic + " цены прайс " + geo + " 2024 2025");
|
queries.put("google_maps", topic + " " + geo + " рейтинг отзывы Google Maps 2GIS лучшие");
|
||||||
queries.put("pain_points", topic + " отзывы проблемы клиенты жалобы " + geo);
|
queries.put("prices", topic + " цены прайс Казахстан 2024 2025");
|
||||||
queries.put("smm_cases", niche + " SMM Instagram продвижение кейс Казахстан");
|
queries.put("pain_points", topic + " отзывы жалобы клиенты проблемы " + geo);
|
||||||
queries.put("search_demand", topic + " частота запросов Казахстан сезонность");
|
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<>());
|
Map<String, SerperSearchResult> results = Collections.synchronizedMap(new LinkedHashMap<>());
|
||||||
|
|
||||||
@@ -196,14 +198,16 @@ public class MarketingAnalysisV3Service {
|
|||||||
|
|
||||||
private String sectionLabel(String key) {
|
private String sectionLabel(String key) {
|
||||||
return switch (key) {
|
return switch (key) {
|
||||||
case "market_overview" -> "ОБЗОР РЫНКА";
|
case "market_overview" -> "ОБЗОР РЫНКА";
|
||||||
case "top_players" -> "ТОПОВЫЕ ИГРОКИ / КОНКУРЕНТЫ";
|
case "instagram_players" -> "ИГРОКИ В INSTAGRAM";
|
||||||
case "google_maps" -> "РЕЙТИНГИ И ОТЗЫВЫ (Google Maps / 2GIS)";
|
case "tiktok_players" -> "ИГРОКИ В TIKTOK";
|
||||||
case "prices" -> "ЦЕНЫ";
|
case "google_maps" -> "РЕЙТИНГИ И ОТЗЫВЫ (Google Maps / 2GIS)";
|
||||||
case "pain_points" -> "БОЛИ КЛИЕНТОВ / ОТЗЫВЫ";
|
case "prices" -> "ЦЕНЫ";
|
||||||
case "smm_cases" -> "SMM КЕЙСЫ";
|
case "pain_points" -> "БОЛИ КЛИЕНТОВ / ОТЗЫВЫ";
|
||||||
case "search_demand" -> "ПОИСКОВЫЙ СПРОС";
|
case "smm_cases" -> "SMM КЕЙСЫ";
|
||||||
default -> key.toUpperCase();
|
case "search_demand" -> "ПОИСКОВЫЙ СПРОС";
|
||||||
|
case "competitor_content" -> "КОНТЕНТ КОНКУРЕНТОВ";
|
||||||
|
default -> key.toUpperCase();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -217,33 +221,60 @@ public class MarketingAnalysisV3Service {
|
|||||||
String cities = String.join(", ", getTargetCities(request));
|
String cities = String.join(", ", getTargetCities(request));
|
||||||
|
|
||||||
return """
|
return """
|
||||||
Ты — старший маркетинговый аналитик рынка Казахстана, специализация: "%s".
|
Ты — старший маркетинговый аналитик рынка Казахстана и СНГ с 15-летним опытом.
|
||||||
|
Твоя специализация: конкурентный анализ, SMM-стратегия, потребительское поведение.
|
||||||
|
|
||||||
ТВОЯ ЦЕЛЬ: Сгенерировать строго валидный JSON-отчёт маркетингового анализа на основе предоставленных данных.
|
КОНТЕКСТ ПРОДУКТА:
|
||||||
|
Этот анализ создаётся для платформы, которая АВТОМАТИЧЕСКИ заменяет SMM-специалиста для малого и среднего бизнеса Казахстана.
|
||||||
|
Пользователи — обычные предприниматели, которые НЕ разбираются в маркетинге. Они хотят:
|
||||||
|
1. Понять, что происходит на рынке прямо сейчас
|
||||||
|
2. Узнать, кто их конкуренты и чем они сильны
|
||||||
|
3. Получить чёткий план действий в соцсетях
|
||||||
|
4. Видеть конкретные цифры, а не общие слова
|
||||||
|
|
||||||
ИСТОЧНИКИ (приоритет по убыванию):
|
ТВОЯ РОЛЬ В ЭТОЙ СИСТЕМЕ:
|
||||||
1. Конкретные факты и названия из блока "ДАННЫЕ ИЗ ПОИСКА" в запросе пользователя
|
Ты генерируешь структурированный JSON-отчёт, который фронтенд превращает в красивый дашборд с графиками, таблицами и визуализациями. От качества твоего JSON зависит, насколько ценным окажется продукт для клиента.
|
||||||
2. Твои знания о рынке Казахстана в нише "%s", городах: %s
|
|
||||||
3. Метод Ферми — только для полей, по которым нет других данных
|
|
||||||
|
|
||||||
ОГРАНИЧИТЕЛИ РЕАЛИЗМА (НАРУШАТЬ ЗАПРЕЩЕНО — это физические ограничения):
|
═══════════════════════════════════════════════════
|
||||||
- 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] ПРИОРИТЕТ ИСТОЧНИКОВ:
|
||||||
1. В 3_competitor_map — только реальные бренды из данных поиска. Если поиск не дал имён — придумай правдоподобные казахстанские названия (НЕ "Компания А", НЕ "Бизнес 1").
|
1. Конкретные факты из блока "ДАННЫЕ ИЗ ПОИСКА" (названия, цифры, ссылки)
|
||||||
2. Каждый город из запроса ОБЯЗАН присутствовать в cityDistribution и cityComparison.
|
2. Твои знания о рынке Казахстана в данной нише
|
||||||
3. NULL в любом поле — ЗАПРЕЩЁН. Пустой список [] или 0 — допустимо.
|
3. Метод Ферми — только если нет данных из п.1 и п.2
|
||||||
4. Все перечисляемые текстовые поля (businessStage, demandTrend, competitionLevel, intensityLabel) — строго на русском языке.
|
|
||||||
5. keyFigures, contributingFactors, commonCta, relatedQueries, strategicFocus — конкретные, с цифрами где возможно.
|
[П2] РЕАЛИЗМ (физические ограничения рынка KZ):
|
||||||
6. Формат вывода: только JSON объект. Никакого markdown, никаких пояснений вне JSON.
|
• 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);
|
""".formatted(niche, niche, cities);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -292,78 +323,187 @@ public class MarketingAnalysisV3Service {
|
|||||||
.collect(Collectors.joining(",\n ", "[\n ", "\n ]"));
|
.collect(Collectors.joining(",\n ", "[\n ", "\n ]"));
|
||||||
|
|
||||||
return """
|
return """
|
||||||
ЗАДАНИЕ: Сгенерируй маркетинговый анализ v4.0 для рынка Казахстана.
|
ЗАДАНИЕ: Сгенерируй полный маркетинговый анализ v4.0 для рынка Казахстана.
|
||||||
|
|
||||||
=== ПРОФИЛЬ КЛИЕНТА ===
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
Ниша: %s
|
ПРОФИЛЬ БИЗНЕСА КЛИЕНТА
|
||||||
Продукт / бренд: %s
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
Цель: %s
|
Ниша: %s
|
||||||
Описание продукта: %s
|
Продукт/Бренд: %s
|
||||||
|
Цель анализа: %s
|
||||||
Этап бизнеса: %s
|
Описание продукта: %s
|
||||||
Целевая аудитория: %s
|
Этап бизнеса: %s
|
||||||
Тип предложения: %s
|
Целевая аудитория: %s
|
||||||
Средний чек: %s
|
Тип предложения: %s
|
||||||
Поведение клиентов: %s
|
Средний чек: %s
|
||||||
|
Поведение клиентов: %s
|
||||||
Охват: %s
|
|
||||||
Основной город: %s
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
Города присутствия: %s
|
ГЕОГРАФИЯ
|
||||||
Города продвижения: %s
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
Охват: %s
|
||||||
Частота покупки: %s
|
Основной город: %s
|
||||||
Визуальный фактор: %s
|
Города присутствия: %s
|
||||||
|
Города продвижения: %s
|
||||||
|
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
ПОВЕДЕНИЕ И ЦЕННОСТИ КЛИЕНТОВ
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
Частота покупки: %s
|
||||||
|
Визуальный фактор: %s
|
||||||
Приоритеты при выборе: %s
|
Приоритеты при выборе: %s
|
||||||
Как находят продукт: %s
|
Как находят продукт: %s
|
||||||
Ценовая реакция: %s
|
Ценовая реакция: %s
|
||||||
|
|
||||||
SMM статус: %s
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
Объём лидов: %s
|
ТЕКУЩЕЕ СОСТОЯНИЕ БИЗНЕСА
|
||||||
Обработка обращений: %s
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
Ограничения бизнеса: %s
|
SMM статус: %s
|
||||||
|
Объём лидов: %s
|
||||||
Ссылки на соцсети клиента: %s
|
Обработка обращений: %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]
|
[3_competitor_map]
|
||||||
- Минимум 5 конкурентов, максимум 10.
|
• Минимум 7, максимум 10 конкурентов
|
||||||
- Используй РЕАЛЬНЫЕ названия из блока "ТОПОВЫЕ ИГРОКИ" выше.
|
• Используй РЕАЛЬНЫЕ названия из поиска — это критично
|
||||||
- Если известные конкуренты указаны клиентом — включи их обязательно.
|
• Клиентские конкуренты из knownCompetitorLinks — включить ОБЯЗАТЕЛЬНО
|
||||||
- strengths и weaknesses: минимум 2 конкретных пункта каждый.
|
• strengths: 3 конкретных сильных стороны (что они делают хорошо в SMM?)
|
||||||
|
• weaknesses: 3 конкретных слабости (что они делают плохо или не делают?)
|
||||||
[1_market_landscape → cityDistribution] — ИСПОЛЬЗУЙ ЭТОТ ШАБЛОН, замени 0 на реальные числа:
|
• Пример слабости: "Нет видео-контента", "Не отвечают на комментарии", "Посты раз в 2 недели"
|
||||||
%s
|
• Пример силы: "Reels каждые 3 дня с >5K просмотров", "Цены в каждом посте", "Stories каждый день"
|
||||||
|
• contentStyle — добавь дополнительное строковое поле: стиль контента конкурента
|
||||||
[2_geo_structure → cityComparison] — ИСПОЛЬЗУЙ ЭТОТ ШАБЛОН, замени 0 на реальные числа:
|
• lastActiveDate — строка: примерная дата последней активности "2024-01" или "активен"
|
||||||
%s
|
|
||||||
|
[4_content_profile]
|
||||||
[9_user_positioning] — %s
|
• Проценты ДОЛЖНЫ в сумме давать 100.0:
|
||||||
|
demoContentPercent + expertContentPercent + salesContentPercent +
|
||||||
[10_structured_conclusions] — минимум 5 фактов с цифрами, НЕ советы.
|
reviewsContentPercent + engagementContentPercent = 100.0
|
||||||
Пример: "Средний ER в нише составляет X%%, что на Y%% выше среднего по KZ"
|
• videoShare — доля видео от всего контента (Reels, TikTok, Stories)
|
||||||
|
• avgTextLengthCharacters — типичная длина подписи к посту в символах
|
||||||
[11_smm_strategy_rationale] — минимум 80 слов.
|
• ctaUsagePercent — %% постов с явным призывом к действию
|
||||||
Учти профиль клиента: тип предложения "%s", аудитория "%s", визуальный фактор "%s".
|
• topHashtags — добавь массив: 5 наиболее используемых хэштегов в нише
|
||||||
Объясни почему именно SMM подходит для этого бизнеса, опираясь на цифры анализа.
|
• bestPostingTime — добавь строку: лучшее время для публикации "18:00–21:00"
|
||||||
|
|
||||||
=== JSON СТРУКТУРА (ЗАПОЛНИ ВСЕ ПОЛЯ РЕАЛЬНЫМИ ДАННЫМИ) ===
|
[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": {
|
"0_executive_summary": {
|
||||||
"businessStage": "строка на русском",
|
"businessStage": "строка на русском",
|
||||||
"geography": "строка на русском",
|
"geography": "строка — перечисление городов",
|
||||||
"activeCompetitors": 0,
|
"activeCompetitors": 0,
|
||||||
"competitionLevel": "низкий | средний | высокий | очень высокий",
|
"competitionLevel": "низкий | средний | высокий | очень высокий",
|
||||||
"averageNicheEr": 0.0,
|
"averageNicheEr": 0.0,
|
||||||
"averageRating": 0.0,
|
"averageRating": 0.0,
|
||||||
"demandTrend": "растущий | стабильный | падающий",
|
"demandTrend": "растущий | стабильный | падающий",
|
||||||
"keyFigures": ["факт с цифрой", "факт с цифрой", "факт", "факт", "факт"]
|
"keyFigures": [
|
||||||
|
"📊 N активных конкурентов в нише по Instagram",
|
||||||
|
"⭐ Средний рейтинг ниши: X.X из 5.0",
|
||||||
|
"📈 Тренд спроса: +X%% год к году",
|
||||||
|
"🎯 Средний ER в нише: X.X%%",
|
||||||
|
"📅 Лидеры публикуют N постов/мес",
|
||||||
|
"💬 N%% бизнесов имеют >50 отзывов"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"1_market_landscape": {
|
"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,
|
"cityDistribution": %s,
|
||||||
"nicheReputationLevel": 0.0
|
"nicheReputationLevel": 0.0
|
||||||
},
|
},
|
||||||
@@ -373,15 +513,18 @@ public class MarketingAnalysisV3Service {
|
|||||||
},
|
},
|
||||||
"3_competitor_map": [
|
"3_competitor_map": [
|
||||||
{
|
{
|
||||||
"name": "Реальное название бренда",
|
"name": "Название бренда",
|
||||||
"platform": "Instagram",
|
"platform": "Instagram",
|
||||||
|
"profileUrl": "https://instagram.com/...",
|
||||||
"followers": 0,
|
"followers": 0,
|
||||||
"postsPerMonth": 0,
|
"postsPerMonth": 0,
|
||||||
"er": 0.0,
|
"er": 0.0,
|
||||||
"rating": 0.0,
|
"rating": 0.0,
|
||||||
"reviews": 0,
|
"reviews": 0,
|
||||||
"strengths": ["сильная сторона 1", "сильная сторона 2"],
|
"contentStyle": "описание стиля контента",
|
||||||
"weaknesses": ["слабая сторона 1", "слабая сторона 2"]
|
"lastActiveDate": "активен",
|
||||||
|
"strengths": ["сила 1 с цифрами", "сила 2 с цифрами", "сила 3"],
|
||||||
|
"weaknesses": ["слабость 1", "слабость 2", "слабость 3"]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"4_content_profile": {
|
"4_content_profile": {
|
||||||
@@ -392,53 +535,112 @@ public class MarketingAnalysisV3Service {
|
|||||||
"engagementContentPercent": 0.0,
|
"engagementContentPercent": 0.0,
|
||||||
"videoShare": 0.0,
|
"videoShare": 0.0,
|
||||||
"avgTextLengthCharacters": 0,
|
"avgTextLengthCharacters": 0,
|
||||||
"ctaUsagePercent": 0.0
|
"ctaUsagePercent": 0.0,
|
||||||
|
"topHashtags": ["#хэштег1", "#хэштег2", "#хэштег3", "#хэштег4", "#хэштег5"],
|
||||||
|
"bestPostingTime": "18:00–21:00"
|
||||||
},
|
},
|
||||||
"5_competition_intensity": {
|
"5_competition_intensity": {
|
||||||
"ciiIndex": 0,
|
"ciiIndex": 0,
|
||||||
"intensityLabel": "строка на русском",
|
"intensityLabel": "строка на русском",
|
||||||
"contributingFactors": ["фактор 1", "фактор 2", "фактор 3"]
|
"contributingFactors": [
|
||||||
|
"Плотность: N активных аккаунтов",
|
||||||
|
"Активность: ср. N постов/мес",
|
||||||
|
"Вовлечённость: ср. ER X.X%%",
|
||||||
|
"Репутация: N%% с рейтингом >4.5"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"6_reputation_analysis": {
|
"6_reputation_analysis": {
|
||||||
"avgNicheRating": 0.0,
|
"avgNicheRating": 0.0,
|
||||||
"medianReviews": 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,
|
"highTrustBusinessShare": 0.0,
|
||||||
"avgOwnerResponseTimeHours": 0
|
"avgOwnerResponseTimeHours": 0,
|
||||||
|
"topComplaintThemes": ["жалоба 1", "жалоба 2", "жалоба 3"],
|
||||||
|
"topPraiseThemes": ["похвала 1", "похвала 2", "похвала 3"]
|
||||||
},
|
},
|
||||||
"7_behavioral_pattern": {
|
"7_behavioral_pattern": {
|
||||||
"promoUsagePercent": 0.0,
|
"promoUsagePercent": 0.0,
|
||||||
"directBookingPercent": 0.0,
|
"directBookingPercent": 0.0,
|
||||||
"priceVisibilityPercent": 0.0,
|
"priceVisibilityPercent": 0.0,
|
||||||
"avgCycleDays": 0,
|
"avgCycleDays": 0,
|
||||||
"commonCta": ["CTA формулировка 1", "CTA формулировка 2", "CTA формулировка 3"]
|
"commonCta": [
|
||||||
|
"CTA фраза 1",
|
||||||
|
"CTA фраза 2",
|
||||||
|
"CTA фраза 3",
|
||||||
|
"CTA фраза 4",
|
||||||
|
"CTA фраза 5"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"8_search_demand": {
|
"8_search_demand": {
|
||||||
"avgMonthlySearches": 0,
|
"avgMonthlySearches": 0,
|
||||||
|
"trendDirection": "Растущий | Стабильный | Падающий",
|
||||||
|
"yoyGrowthPercent": 0.0,
|
||||||
"seasonality": [
|
"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"],
|
"peakPeriods": ["Период 1 с объяснением", "Период 2 с объяснением"],
|
||||||
"relatedQueries": ["запрос 1", "запрос 2", "запрос 3"]
|
"relatedQueries": [
|
||||||
|
"запрос 1", "запрос 2", "запрос 3",
|
||||||
|
"запрос 4", "запрос 5", "запрос 6"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"9_user_positioning": {
|
"9_user_positioning": {
|
||||||
"marketMetrics": {"activity": 0, "engagement": 0, "video": 0, "reputation": 0, "frequency": 0},
|
"marketMetrics": {
|
||||||
"userMetrics": {"activity": 0, "engagement": 0, "video": 0, "reputation": 0, "frequency": 0},
|
"activity": 0,
|
||||||
"mode": "BENCHMARK",
|
"engagement": 0,
|
||||||
"strategicFocus": ["приоритет 1", "приоритет 2", "приоритет 3"]
|
"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"],
|
"10_structured_conclusions": [
|
||||||
"11_smm_strategy_rationale": "Минимум 80 слов с конкретными цифрами."
|
"КОНКУРЕНЦИЯ: факт. Что это значит.",
|
||||||
|
"КОНТЕНТ: факт. Что это значит.",
|
||||||
|
"РЕПУТАЦИЯ: факт. Что это значит.",
|
||||||
|
"СПРОС: факт. Что это значит.",
|
||||||
|
"ЦЕНЫ: факт. Что это значит.",
|
||||||
|
"ВИДЕО: факт. Что это значит.",
|
||||||
|
"ВОЗМОЖНОСТЬ: факт. Что это значит."
|
||||||
|
],
|
||||||
|
"11_smm_strategy_rationale": "Минимум 120 слов. ПОЧЕМУ SMM → ПОЧЕМУ СЕЙЧАС → ЧТО ДЕЛАТЬ → ПЕРВЫЙ ШАГ."
|
||||||
}
|
}
|
||||||
|
|
||||||
ОТВЕТ: только JSON. Никакого текста вне JSON.
|
ОТВЕТ: только JSON. Никакого текста вне JSON. Никаких markdown-блоков ```json.
|
||||||
""".formatted(
|
""".formatted(
|
||||||
// Профиль клиента
|
|
||||||
sanitize(request.getBusinessNiche()),
|
sanitize(request.getBusinessNiche()),
|
||||||
sanitize(request.getProductName()),
|
sanitize(request.getProductName()),
|
||||||
sanitize(request.getGoal()),
|
sanitize(request.getGoal()),
|
||||||
@@ -463,17 +665,13 @@ public class MarketingAnalysisV3Service {
|
|||||||
constraintsDesc,
|
constraintsDesc,
|
||||||
String.join(", ", safeList(request.getUserSocialLinks())),
|
String.join(", ", safeList(request.getUserSocialLinks())),
|
||||||
String.join(", ", safeList(request.getKnownCompetitorLinks())),
|
String.join(", ", safeList(request.getKnownCompetitorLinks())),
|
||||||
// Evidence block
|
|
||||||
evidence,
|
evidence,
|
||||||
// Требования к блокам
|
|
||||||
cityDistJson,
|
cityDistJson,
|
||||||
cityCompJson,
|
cityCompJson,
|
||||||
positioningNote,
|
positioningNote,
|
||||||
// Параметры для rationale
|
|
||||||
offerTypeDesc,
|
offerTypeDesc,
|
||||||
clientTargetDesc,
|
clientTargetDesc,
|
||||||
visualFactorDesc,
|
visualFactorDesc,
|
||||||
// JSON шаблон городов
|
|
||||||
cityDistJson,
|
cityDistJson,
|
||||||
cityCompJson
|
cityCompJson
|
||||||
);
|
);
|
||||||
@@ -574,6 +772,28 @@ public class MarketingAnalysisV3Service {
|
|||||||
if (cp.getFollowers() < 0) cp.setFollowers(0);
|
if (cp.getFollowers() < 0) cp.setFollowers(0);
|
||||||
if (cp.getPostsPerMonth() < 0) cp.setPostsPerMonth(0);
|
if (cp.getPostsPerMonth() < 0) cp.setPostsPerMonth(0);
|
||||||
if (cp.getReviews() < 0) cp.setReviews(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);
|
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) {
|
if (r.getCompetitionIntensity() != null) {
|
||||||
@@ -602,9 +824,22 @@ public class MarketingAnalysisV3Service {
|
|||||||
clamp(r.getGeoStructure().getDensityIndex(), 0.0, 100.0));
|
clamp(r.getGeoStructure().getDensityIndex(), 0.0, 100.0));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Авто-вычисление gap и нормализация radar-метрик
|
||||||
if (r.getUserPositioning() != null) {
|
if (r.getUserPositioning() != null) {
|
||||||
fixRadar(r.getUserPositioning().getMarketMetrics());
|
var up = r.getUserPositioning();
|
||||||
fixRadar(r.getUserPositioning().getUserMetrics());
|
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");
|
log.info("fixConstraints passed — result is physically valid");
|
||||||
@@ -619,6 +854,10 @@ public class MarketingAnalysisV3Service {
|
|||||||
m.setFrequency( (int) clamp(m.getFrequency(), 0, 100));
|
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) {
|
private double clamp(double val, double min, double max) {
|
||||||
return Math.max(min, Math.min(max, val));
|
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("aspectRatio", "1:1");
|
||||||
parameters.put("safetyFilterLevel", "block_some");
|
parameters.put("safetyFilterLevel", "block_some");
|
||||||
parameters.put("personGeneration", "allow_adult");
|
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);
|
body.put("parameters", parameters);
|
||||||
return body;
|
return body;
|
||||||
|
|||||||
Reference in New Issue
Block a user