This commit is contained in:
arys
2026-01-19 14:59:26 +05:00
parent 9498bdb233
commit 6222e5dcb6
4 changed files with 205 additions and 1268 deletions
+19 -4
View File
@@ -161,14 +161,29 @@
<version>0.12.3</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
@@ -10,10 +10,16 @@ import kz.konturai.parser.validator.ValidAnalysisType;
import kz.konturai.parser.validator.ValidDetailLevel;
import kz.konturai.parser.validator.ValidRegion;
import kz.konturai.parser.validator.ValidTargetAudience;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
import java.util.Map;
@Data
@AllArgsConstructor
@NoArgsConstructor
public class MarketingAnalysisRequest {
@NotBlank(message = "Поле 'businessNiche' обязательно для заполнения")
@@ -52,98 +58,9 @@ public class MarketingAnalysisRequest {
@ValidAnalysisType(message = "Поле 'analysisType' должно содержать один или несколько типов: РЫНОК, КОНКУРЕНТЫ, ЦА, КАНАЛЫ, SWOT")
private List<String> analysisType;
public MarketingAnalysisRequest() {
}
public MarketingAnalysisRequest(String businessNiche, String product, Map<String, Object> targetAudience, List<String> region,
String goal, String detailLevel, String strongSide, String weakSide, List<String> analysisType) {
this.businessNiche = businessNiche;
this.product = product;
this.targetAudience = targetAudience;
this.region = region;
this.goal = goal;
this.detailLevel = detailLevel;
this.strongSide = strongSide;
this.weakSide = weakSide;
this.analysisType = analysisType;
}
public String getBusinessNiche() {
return businessNiche;
}
public void setBusinessNiche(String businessNiche) {
this.businessNiche = businessNiche;
}
public String getProduct() {
return product;
}
public void setProduct(String product) {
this.product = product;
}
public Map<String, Object> getTargetAudience() {
return targetAudience;
}
public void setTargetAudience(Map<String, Object> targetAudience) {
this.targetAudience = targetAudience;
}
public List<String> getRegion() {
return region;
}
public void setRegion(List<String> region) {
this.region = region;
}
public String getGoal() {
return goal;
}
public void setGoal(String goal) {
this.goal = goal;
}
public String getDetailLevel() {
return detailLevel;
}
public void setDetailLevel(String detailLevel) {
this.detailLevel = detailLevel;
}
public DetailLevel getDetailLevelEnum() {
return DetailLevel.fromString(detailLevel);
}
public String getStrongSide() {
return strongSide;
}
public void setStrongSide(String strongSide) {
this.strongSide = strongSide;
}
public String getWeakSide() {
return weakSide;
}
public void setWeakSide(String weakSide) {
this.weakSide = weakSide;
}
public List<String> getAnalysisType() {
return analysisType;
}
public void setAnalysisType(List<String> analysisType) {
this.analysisType = analysisType;
}
public List<AnalysisType> getAnalysisTypeEnums() {
if (analysisType == null) {
return List.of();
File diff suppressed because it is too large Load Diff
@@ -12,6 +12,7 @@ import kz.konturai.parser.model.MarketingAnalysis;
import kz.konturai.parser.model.MarketingAnalysisV2Document;
import kz.konturai.parser.repository.MarketingAnalysisRepository;
import kz.konturai.parser.repository.MarketingAnalysisV2Repository;
import lombok.RequiredArgsConstructor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
@@ -31,18 +32,20 @@ import jakarta.annotation.PostConstruct;
import org.springframework.web.reactive.function.client.WebClientResponseException;
@Service
@RequiredArgsConstructor
public class MarketingAnalysisService {
private static final String KZ_MARKET_CONTEXT =
"РОЛЬ: Ты — Партнер международной консалтинговой фирмы (Big 4: McKinsey/KPMG) с 50-летним опытом в маркетинге и стратегии.\n" +
"Твоя специализация: Рынки Центральной Азии (особенно Казахстан).\n" +
"СТИЛЬ: Уверенный, профессиональный, доказательный, без 'воды'. Используй термины (LTV, CAC, Retention, Unit-economics).\n" +
"КОНТЕКСТ РЫНКА:\n" +
"- Страна: Казахстан (Алматы, Астана, Шымкент - ключевые хабы).\n" +
"- Валюта: Тенге (KZT). Все цены и доходы — только в тенге.\n" +
"- Инфляция: Реальная потребительская инфляция ~10-12% (официальная ~8%).\n" +
"- Поведение: Высокая роль Kaspi (QR, рассрочка 0-0-12), Instagram (витрина), WhatsApp (коммуникация).\n" +
"- Менталитет: 'Уят' (репутация), семейные ценности, склонность к статусному потреблению, чувствительность к цене (акции).\n";
"РОЛЬ: Ты — Партнер международной консалтинговой фирмы (Big 4: McKinsey/BCG/PwC) с офисом в Алматы.\n" +
"СПЕЦИАЛИЗАЦИЯ: Глубокий анализ рынка Казахстана. Ты мыслишь в Тенге (KZT).\n" +
"ТВОИ ЗНАНИЯ О РЫНКЕ KZ (СТРОГИЕ ПРАВИЛА):\n" +
"1. SuperApps: Kaspi.kz — это экосистема №1 (Магазин, Travel, Gov, Платежи). Это главный канал продаж для товарки и услуг.\n" +
"2. Карты: 2GIS в Казахстане популярнее Google Maps для поиска услуг офлайн. Отзывы в 2GIS критически важны.\n" +
"3. Фитнес-рынок: Лидеры — 1Fit (единый абонемент, НЕ переводи это название), Invictus, World Class, FitnessBlitz.\n" +
"4. Соцсети: Instagram (Direct/Reels) и TikTok — главные каналы продаж. ВКонтакте и Threads — мертвы для коммерции в РК, ЗАПРЕЩЕНО их рекомендовать.\n" +
"5. Экономика: Реальная инфляция ~12-15%. Потребитель чувствителен к цене, любит рассрочки (Kaspi Red/Kredit).\n" +
"ВАЛЮТА: Только KZT. Округляй цифры до сотен (например, 15 000 KZT, а не 14 321).\n";
private static final String AI_MANDATORY_JSON_USAGE_WARNING = "ВНИМАНИЕ: Если в предоставленном JSON есть данные, ты ОБЯЗАН использовать их и вставить соответствующий тег [[CHART_...]]. "
+ "Никогда не пиши, что данных нет, если JSON не пустой.\n\n";
private static final String AI_ULTIMATE_JSON_PRESENCE_INSTRUCTION = ""
@@ -423,21 +426,6 @@ public class MarketingAnalysisService {
private Semaphore requestSemaphore;
public MarketingAnalysisService(
MarketingAnalysisRepository repository,
MarketingAnalysisV2Repository v2Repository,
OpenAIAnalyticsService openAIAnalyticsService,
MinIOService minIOService,
ResearchPdfService researchPdfService,
SerperSearchService serperSearchService) {
this.repository = repository;
this.v2Repository = v2Repository;
this.openAIAnalyticsService = openAIAnalyticsService;
this.minIOService = minIOService;
this.researchPdfService = researchPdfService;
this.serperSearchService = serperSearchService;
}
@PostConstruct
public void initializeSemaphore() {
this.requestSemaphore = new Semaphore(maxConcurrentRequests, true);
@@ -473,17 +461,14 @@ public class MarketingAnalysisService {
String topic = (niche != null && !niche.isBlank()) ? niche : (product != null ? product : "ниша");
// Prefer the requested domains but keep query broad enough to return something.
String preferredSites = "(site:stat.gov.kz OR site:kapital.kz OR site:kursiv.media OR site:forbes.kz OR site:pwc.kz OR site:kpmg.kz)";
// Added for real estate/auto context if needed
String extraSites = "OR site:kolesa.kz OR site:krisha.kz OR site:market.kz OR site:hh.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<>();
String query1 = topic + " рынок " + regionStr + " 2024 2025 статистика объем рост " + preferredSites;
String query1 = topic + " рынок " + regionStr + " 2024 2025 статистика объем рост " + preferredSites + retailSites;
try {
general.add(serperSearchService.search(query1));
} catch (Exception e) {
// Handle DNS/network errors... (keeping existing error handling)
String errorType = "Unexpected error";
if (e instanceof java.net.UnknownHostException || e instanceof java.net.ConnectException)
errorType = "DNS/Network failure";
@@ -493,7 +478,7 @@ public class MarketingAnalysisService {
general.add(new SerperSearchResult(query1, "ERROR", errorType + ": " + e.getMessage(), List.of()));
}
String query2 = "инфляция Казахстан 2024 2025 курс тенге 2024 2025 логистика импорт " + preferredSites;
String query2 = "инфляция Казахстан 2024 2025 курс тенге 2024 2025 логистика импорт " + preferredSites + retailSites;
try {
general.add(serperSearchService.search(query2));
} catch (Exception e) {
@@ -559,14 +544,19 @@ public class MarketingAnalysisService {
followups.put("income", new SerperSearchResult(incomeQuery, "ERROR", e.getMessage(), List.of()));
}
String demandQuery = topic + " сезонность спроса динамика популярности " + regionStr + " когда покупают google trends";
String demandQuery = String.format("когда сезон %s в %s динамика спроса google trends", topic, regionStr);
try {
followups.put("demand", serperSearchService.search(demandQuery));
} catch (Exception e) {
logger.warn("Serper API error for demand query: {}", e.getMessage());
followups.put("demand", new SerperSearchResult(demandQuery, "ERROR", e.getMessage(), List.of()));
logger.warn("Serper error demand: {}", e.getMessage());
}
String channelsQuery = String.format("%s реклама кейсы казахстан instagram tiktok kaspi маркетинг таргетолог", topic);
try {
followups.put("channels", serperSearchService.search(channelsQuery));
} catch (Exception e) {
logger.warn("Serper error channels: {}", e.getMessage());
}
pack.put("general", general);
pack.put("followups", followups);
@@ -649,10 +639,6 @@ public class MarketingAnalysisService {
return List.of(analysis);
}
/**
* V2 endpoint payload for frontend integration testing.
* Generates a strict JSON response via OpenAI, grounded on Serper web evidence.
*/
public MarketingAnalysisResponseV2 generateAnalysisV2(MarketingAnalysisRequest request) {
logger.info("Starting Marketing Analysis V2 (Big 4 Standard) for request: {}", request);
@@ -664,7 +650,6 @@ public class MarketingAnalysisService {
MarketingAnalysisResponseV2 exampleDto = buildAnalysisV2ExampleForPrompt(request, competitorCompanies);
exampleDto.setSources(List.of("https://stat.gov.kz/example", "https://forbes.kz/example"));
String structureExampleJson = objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(exampleDto);
// -------------------------------------------------------------------------
String systemPrompt = buildSystemPromptForKazakhstan();
String instruction = buildDetailedInstruction(request, competitorCompanies, structureExampleJson);
@@ -674,28 +659,24 @@ public class MarketingAnalysisService {
"evidence", researchPack
));
String lastCleanedResponse = null;
for (int attempt = 1; attempt <= 2; attempt++) {
String aiResponse = openAIAnalyticsService.generateWithInstructionWithModel(
contextJson,
instruction,
"ru",
textModelName,
systemPrompt,
16000,
180000L
);
String cleaned = cleanJsonFromMarkdown(aiResponse);
// ... (очистка \uFEFF и валидация плейсхолдеров)
try {
String aiResponse = openAIAnalyticsService.generateWithInstructionWithModel(
contextJson,
instruction,
"ru",
textModelName,
systemPrompt,
16000,
180000L
);
String cleaned = cleanJsonFromMarkdown(aiResponse);
MarketingAnalysisResponseV2 parsed = objectMapper.readValue(cleaned, MarketingAnalysisResponseV2.class);
replacePlaceholderCompanyKeysIfNeeded(parsed, competitorCompanies);
return parsed;
} catch (Exception e) {
logger.error("JSON Parsing failed on attempt {}: {}", attempt, e.getMessage());
logger.error("JSON Parsing/Generation failed on attempt {}: {}", attempt, e.getMessage());
if (attempt == 2) throw e;
}
}
@@ -706,79 +687,67 @@ public class MarketingAnalysisService {
throw new RuntimeException("Не удалось сгенерировать маркетинговый анализ V2: " + e.getMessage(), e);
}
}
/**
* Строит системный промпт, задающий ролевую модель и контекст Казахстана.
*/
private String buildSystemPromptForKazakhstan() {
return """
Ты Партнер международной консалтинговой фирмы (Big 4: McKinsey, BCG, PwC) с офисом в Алматы.
Твой опыт: 50 лет в маркетинговой стратегии и аналитике рынков Центральной Азии.
ТВОЯ СПЕЦИАЛИЗАЦИЯ КАЗАХСТАН (KZ):
1. **Экономика**: Ты мыслишь в Тенге (KZT). Ты понимаешь реальную покупательскую способность, закредитованность населения и инфляцию.
2. **Платформы**: Ты знаешь, что в РК **Kaspi.kz** это экосистема 1 (Shop, Travel, Pay).
Ты знаешь, что **Instagram** и **TikTok** продают лучше, чем сайты.
Ты знаешь, что **2GIS** важнее для локального бизнеса, чем Google Maps.
Ты практически игнорируешь "ВКонтакте", "Яндекс.Маркет" или "Ozon", если только это не специфичный трансграничный кейс.
3. **Менталитет**: Ты учитываешь важность рекомендаций ("сарафан"), семейных связей, статусности потребления и любовь к рассрочкам (Kaspi Red/Kredit).
ТВОЙ СТИЛЬ:
- Жесткий, фактологический, структурный.
- Никакой "воды" и общих фраз ("динамично развивающийся рынок"). Только инсайты.
- Ты презираешь выдуманные данные. Если точной цифры нет в evidence ты делаешь обоснованную аналитическую эстимацию (Analytical Extrapolation), базируясь на смежных метриках.
ФОРМАТ ОТВЕТА:
- ТОЛЬКО валидный JSON.
- Никакого Markdown форматирования (```json ... ```) вокруг ответа.
""";
return KZ_MARKET_CONTEXT +
"\nФОРМАТ ОТВЕТА:\n" +
"- ТОЛЬКО валидный JSON.\n" +
"- Никакого Markdown форматирования (```json ... ```) вокруг ответа.\n" +
"- Ключи JSON должны быть в snake_case (например, market_overview, monthly_online_index).\n" +
"- Запрещено возвращать null в полях (особенно income_by_age) — делай экстраполяцию если нет точных данных.\n";
}
/**
* Строит детальную инструкцию по заполнению полей JSON.
*/
private String buildDetailedInstruction(MarketingAnalysisRequest request, List<String> competitorCompanies, String structureExampleJson) {
String ageRangesInstruction = "";
// ... (логика формирования ageRangesInstruction остается прежней)
List<String> regions = request.getRegion();
String regionsInstruction;
if (regions != null && !regions.isEmpty()) {
String regionsList = String.join(", ", regions);
regionsInstruction = "ГЕОГРАФИЯ (СТРОГО ОБЯЗАТЕЛЬНО):\n" +
"В секции 'market_overview.geography' ты ОБЯЗАН вернуть данные ДЛЯ КАЖДОГО города из этого списка: [" + regionsList + "].\n" +
"НЕ ПРОПУСКАЙ ни одного города из списка. Значение (0-100) должно отражать относительный спрос.";
} else {
regionsInstruction = "ГЕОГРАФИЯ: Алматы, Астана, Шымкент (по умолчанию).";
}
String channelsInstruction = "КАНАЛЫ (РК SPECS):\n" +
"- В 'acquisition_channels' обязательно включи: Instagram, TikTok, Kaspi Marketing, 2GIS, Google Ads.\n" +
"- ЗАПРЕЩЕНО включать или 'ВКонтакте' как платные каналы (они не работают в РК). Если они есть в примере - удали их.\n" +
"- Оценивай эффективность реально: 2GIS работает локально, Kaspi дает горячих лидов.";
String audienceInstruction = "АУДИТОРИЯ:\n" +
"- Следи, чтобы возрастные группы в 'age_structure' НЕ пересекались по логике, но если входные данные пересекаются - нормализуй их так, чтобы сумма % была равна 100%.\n" +
"- Поле 'income_by_age' ОБЯЗАТЕЛЬНО для заполнения (средний доход в KZT).";
return String.format("""
Твоя задача: Провести глубокий маркетинговый анализ и вернуть его СТРОГО в формате JSON.
Твоя задача: Провести глубокий маркетинговый анализ для рынка Казахстана и вернуть JSON.
Ниша: %s
Продукт: %s
Регион: %s
ПРАВИЛА ЗАПОЛНЕНИЯ:
1. **СТРУКТУРА**: Ты обязан использовать структуру JSON, приведенную ниже.
- НЕ переименовывай ключи (например, используй `competitive_environment`, а не `competitors`).
- НЕ пропускай секции.
- Заполни все поля `EXAMPLE__` и `-9999` реальными данными и аналитическими оценками на основе `evidence`.
2. **ИСТОЧНИКИ (sources)**:
- В корневом поле `sources` перечисли ВСЕ реальные URL-адреса из `evidence`, которые ты использовал.
- Это обязательное требование для фронтенда.
3. **ЛОКАЛИЗАЦИЯ (Казахстан)**:
- Валюта: Тенге (KZT).
- Каналы: Instagram, Kaspi, 2GIS, TikTok.
- Данные: Опирайся на Бюро национальной статистики РК и локальные отчеты.
4. **ФОРМАТ**:
- Числа вместо -9999.
- Строки вместо EXAMPLE__.
- Только валидный JSON без Markdown.
%s
ЭТАЛОННАЯ СТРУКТУРА ДАННЫХ ДЛЯ ЗАПОЛНЕНИЯ:
%s
%s
ПРАВИЛА ЦИФР И ЭКОНОМИКИ:
1. Все цены в KZT.
2. Округляй до красивых чисел (например, 15 000, а не 14 321).
3. Прибыльность: Будь реалистом. Маржа в офлайн-бизнесе редко превышает 20-30%. Не рисуй сверхприбыли.
4. В полях cost/value пиши строку с валютой (например "5 000 KZT").
5. В полях status/efficiency пиши понятные оценки: "High Impact", "Low Efficiency", "Moderate".
ЭТАЛОННАЯ СТРУКТУРА JSON (Snake Case):
%s
""",
request.getBusinessNiche(),
request.getProduct(),
request.getRegion() != null ? request.getRegion() : "Казахстан",
ageRangesInstruction,
regionsInstruction,
channelsInstruction,
audienceInstruction,
structureExampleJson
);
}
private String buildJsonStructureExample() {
return """
{
@@ -796,56 +765,6 @@ public class MarketingAnalysisService {
""";
}
private boolean containsV2ExamplePlaceholders(String json) {
if (json == null || json.isBlank()) {
return true;
}
// Explicit placeholder tokens from buildAnalysisV2ExampleForPrompt()
if (json.contains("EXAMPLE__")) {
return true;
}
// Numeric placeholders from buildAnalysisV2ExampleForPrompt()
if (json.contains("-9999")) {
return true;
}
// Check placeholder competitor keys specifically inside competitive_environment
// maps.
// This avoids false positives for normal Russian words like "лид/оплата"
// appearing in conclusions.
try {
JsonNode root = objectMapper.readTree(json);
JsonNode ce = root.at("/sections/competitive_environment");
if (ce == null || ce.isMissingNode()) {
return false;
}
JsonNode dvb = ce.get("digital_visibility_benchmarks");
if (dvb != null && dvb.isObject()) {
Iterator<String> it = dvb.fieldNames();
while (it.hasNext()) {
if (isPlaceholderCompetitorKey(it.next())) {
return true;
}
}
}
JsonNode strengths = ce.get("competitor_strengths");
if (strengths != null && strengths.isObject()) {
Iterator<String> it = strengths.fieldNames();
while (it.hasNext()) {
if (isPlaceholderCompetitorKey(it.next())) {
return true;
}
}
}
return false;
} catch (Exception e) {
// If we can't parse JSON here, treat as unusable and retry.
return true;
}
}
private boolean isPlaceholderCompetitorKey(String key) {
if (key == null) {
return false;
@@ -862,38 +781,61 @@ public class MarketingAnalysisService {
}
private List<String> resolveCompetitorCompaniesForBenchmarks(MarketingAnalysisRequest request,
Map<String, Object> researchPack,
int limit) {
int max = Math.max(1, Math.min(limit, 10));
Map<String, Object> researchPack,
int limit) {
int max = Math.max(3, Math.min(limit, 15));
LinkedHashSet<String> names = new LinkedHashSet<>();
SerperSearchResult fromPack = null;
if (researchPack != null) {
Object followupsObj = researchPack.get("followups");
if (followupsObj instanceof Map<?, ?> followups) {
Object compObj = followups.get("competitors");
if (compObj instanceof SerperSearchResult sr) {
fromPack = sr;
if (followups.get("competitors") instanceof SerperSearchResult sr) {
names.addAll(extractCompanyNamesFromSerper(sr, max));
}
if (names.size() < 3 && followups.get("market") instanceof SerperSearchResult sr) {
names.addAll(extractCompanyNamesFromSerper(sr, max));
}
}
if (names.isEmpty() && researchPack.get("general") instanceof List<?> list) {
for (Object item : list) {
if (item instanceof SerperSearchResult sr) {
names.addAll(extractCompanyNamesFromSerper(sr, max));
}
}
}
}
names.addAll(extractCompanyNamesFromSerper(fromPack, max));
// If competitors query returned mostly directories/articles, do a second query
// to get more brand-like results.
if (names.size() < max) {
String topic = deriveTopic(request);
String regionStr = deriveRegionStr(request);
String q = topic + " " + regionStr + " официальный сайт";
String topic = deriveTopic(request);
String regionStr = deriveRegionStr(request);
if (names.size() < 4) {
String qRating = String.format("топ 10 лучших компаний %s %s рейтинг список", topic, regionStr);
try {
SerperSearchResult extra = serperSearchService.search(q);
names.addAll(extractCompanyNamesFromSerper(extra, max - names.size()));
} catch (Exception ignored) {
}
SerperSearchResult ratingResult = serperSearchService.search(qRating);
names.addAll(extractCompanyNamesFromSerper(ratingResult, max - names.size()));
} catch (Exception ignored) {}
}
if (names.size() < 4) {
String qMaps = String.format("%s магазины %s адреса 2gis", topic, regionStr);
try {
SerperSearchResult mapsResult = serperSearchService.search(qMaps);
names.addAll(extractCompanyNamesFromSerper(mapsResult, max - names.size()));
} catch (Exception ignored) {}
}
names.removeIf(n -> {
String lower = n.toLowerCase().trim();
if (lower.length() < 2 || lower.length() > 50) return true;
if (lower.contains("обзор") || lower.contains("отзывы") || lower.contains("как выбрать")) return true;
return false;
});
List<String> out = new ArrayList<>(names);
return out.size() > max ? out.subList(0, max) : out;
int resultLimit = Math.min(out.size(), Math.max(limit, 3));
return out.subList(0, resultLimit);
}
private String deriveTopic(MarketingAnalysisRequest request) {
@@ -948,7 +890,6 @@ public class MarketingAnalysisService {
}
h = h.startsWith("www.") ? h.substring(4) : h;
// Ignore obvious aggregators/social networks.
String[] blocked = new String[] {
"google.", "yandex.", "2gis.", "instagram.", "facebook.", "vk.", "tiktok.", "youtube.", "wikipedia."
};
@@ -1074,13 +1015,7 @@ public class MarketingAnalysisService {
}
}
/**
* Генерирует анализ v2 и сохраняет его в MongoDB
*
* @param request Запрос на анализ
* @param userId ID пользователя
* @return ID сохраненного анализа
*/
public String generateAndSaveAnalysisV2(MarketingAnalysisRequest request, String userId) {
try {
// Генерируем анализ
@@ -1110,32 +1045,14 @@ public class MarketingAnalysisService {
}
}
/**
* Получает сохраненный анализ v2 по ID
*
* @param id ID анализа
* @return Документ анализа или null, если не найден
*/
public Optional<MarketingAnalysisV2Document> getAnalysisV2ById(String id) {
return v2Repository.findById(id);
}
/**
* Обновляет анализ в базе данных
*
* @param analysis Анализ для обновления
* @return Обновленный анализ
*/
public MarketingAnalysis updateAnalysis(MarketingAnalysis analysis) {
return repository.save(analysis);
}
/**
* Связывает v2 анализ с обычным анализом
*
* @param v2AnalysisId ID v2 анализа
* @param analysisId ID обычного анализа
*/
public void linkV2AnalysisToAnalysis(String v2AnalysisId, String analysisId) {
Optional<MarketingAnalysisV2Document> optV2 = v2Repository.findById(v2AnalysisId);
if (optV2.isPresent()) {
@@ -1146,24 +1063,10 @@ public class MarketingAnalysisService {
}
}
/**
* Получает v2 анализ по ID обычного анализа
*
* @param analysisId ID обычного анализа
* @return Документ v2 анализа или null, если не найден
*/
public Optional<MarketingAnalysisV2Document> getV2AnalysisByAnalysisId(String analysisId) {
return v2Repository.findByAnalysisId(analysisId);
}
/**
* Example payload used ONLY for prompting the AI with the expected JSON
* schema/shape.
* This is not returned to the client.
*
* @param request Запрос на анализ для динамического формирования примера
* age_structure
*/
private MarketingAnalysisResponseV2 buildAnalysisV2ExampleForPrompt(MarketingAnalysisRequest request,
List<String> competitorCompanies) {
MarketingAnalysisResponseV2 response = new MarketingAnalysisResponseV2();
@@ -1171,7 +1074,6 @@ public class MarketingAnalysisService {
MarketingAnalysisResponseV2.Sections sections = new MarketingAnalysisResponseV2.Sections();
// market_overview
MarketingAnalysisResponseV2.MarketOverview marketOverview = new MarketingAnalysisResponseV2.MarketOverview();
marketOverview.setTitle("EXAMPLE__MARKET_OVERVIEW_TITLE");
MarketingAnalysisResponseV2.MarketOverviewMetrics moMetrics = new MarketingAnalysisResponseV2.MarketOverviewMetrics();
@@ -1202,7 +1104,6 @@ public class MarketingAnalysisService {
marketOverview.setConclusion("EXAMPLE__MARKET_OVERVIEW_CONCLUSION");
sections.setMarketOverview(marketOverview);
// target_audience
MarketingAnalysisResponseV2.TargetAudience targetAudience = new MarketingAnalysisResponseV2.TargetAudience();
targetAudience.setTitle("EXAMPLE__TARGET_AUDIENCE_TITLE");
MarketingAnalysisResponseV2.TargetAudienceMetrics taMetrics = new MarketingAnalysisResponseV2.TargetAudienceMetrics();
@@ -1217,28 +1118,22 @@ public class MarketingAnalysisService {
"EXAMPLE__AUDIENCE_SEGMENT_SHARE_2"),
new MarketingAnalysisResponseV2.AudienceSegment("EXAMPLE__AUDIENCE_SEGMENT_3",
"EXAMPLE__AUDIENCE_SEGMENT_SHARE_3")));
// Динамически формируем age_structure на основе реальных данных из запроса
Map<String, String> ageStructure = new LinkedHashMap<>();
Map<String, Object> targetAudienceData = request.getTargetAudience();
if (targetAudienceData != null && targetAudienceData.containsKey("ageRanges")) {
@SuppressWarnings("unchecked")
List<String> ageRanges = (List<String>) targetAudienceData.get("ageRanges");
if (ageRanges != null && !ageRanges.isEmpty()) {
// Используем только те возрастные группы, которые указаны в запросе
for (String ageRange : ageRanges) {
// Нормализуем формат ключа: "55+" -> "55_plus", остальные оставляем как есть
String normalizedKey = ageRange.replace("+", "_plus");
// Формируем плейсхолдер для примера
String placeholderKey = normalizedKey.replace("-", "_").toUpperCase();
ageStructure.put(normalizedKey, "EXAMPLE__AGE_" + placeholderKey + "_SHARE");
}
} else {
// Если возрастные группы не указаны, используем дефолтный пример
ageStructure.put("25-34", "EXAMPLE__AGE_25_34_SHARE");
ageStructure.put("35-44", "EXAMPLE__AGE_35_44_SHARE");
}
} else {
// Если targetAudience не указан, используем дефолтный пример
ageStructure.put("25-34", "EXAMPLE__AGE_25_34_SHARE");
ageStructure.put("35-44", "EXAMPLE__AGE_35_44_SHARE");
}
@@ -1246,7 +1141,6 @@ public class MarketingAnalysisService {
targetAudience.setConclusion("EXAMPLE__TARGET_AUDIENCE_CONCLUSION");
sections.setTargetAudience(targetAudience);
// competitive_environment
MarketingAnalysisResponseV2.CompetitiveEnvironment competitiveEnvironment = new MarketingAnalysisResponseV2.CompetitiveEnvironment();
competitiveEnvironment.setTitle("EXAMPLE__COMPETITIVE_ENVIRONMENT_TITLE");
MarketingAnalysisResponseV2.CompetitiveEnvironmentMetrics ceMetrics = new MarketingAnalysisResponseV2.CompetitiveEnvironmentMetrics();
@@ -1273,7 +1167,6 @@ public class MarketingAnalysisService {
competitiveEnvironment.setConclusion("EXAMPLE__COMPETITIVE_ENVIRONMENT_CONCLUSION");
sections.setCompetitiveEnvironment(competitiveEnvironment);
// demand_and_behavior
MarketingAnalysisResponseV2.DemandAndBehavior demandAndBehavior = new MarketingAnalysisResponseV2.DemandAndBehavior();
demandAndBehavior.setTitle("EXAMPLE__DEMAND_AND_BEHAVIOR_TITLE");
MarketingAnalysisResponseV2.DemandAndBehaviorMetrics dabMetrics = new MarketingAnalysisResponseV2.DemandAndBehaviorMetrics();
@@ -1303,7 +1196,6 @@ public class MarketingAnalysisService {
demandAndBehavior.setConclusion("EXAMPLE__DEMAND_AND_BEHAVIOR_CONCLUSION");
sections.setDemandAndBehavior(demandAndBehavior);
// customer_journey
MarketingAnalysisResponseV2.CustomerJourney customerJourney = new MarketingAnalysisResponseV2.CustomerJourney();
customerJourney.setTitle("EXAMPLE__CUSTOMER_JOURNEY_TITLE");
MarketingAnalysisResponseV2.FunnelMetrics funnelMetrics = new MarketingAnalysisResponseV2.FunnelMetrics();
@@ -1557,11 +1449,7 @@ public class MarketingAnalysisService {
}
}
/**
* Генерация структурированного JSON с данными для графиков
* Использует модульный подход: три отдельных запроса к OpenAI для повышения
* детализации
*/
private Map<String, Object> generateJsonAnalysis(MarketingAnalysisRequest request) {
Map<String, Object> result = new HashMap<>();
@@ -1686,7 +1574,7 @@ public class MarketingAnalysisService {
StringBuilder promptBuilder = new StringBuilder();
promptBuilder.append(
"Ты — профессиональный маркетинговый аналитик и психолог, работающий с малым и средним бизнесом в Казахстане.\n\n");
"Ты — профессиональный маркетинговый аналитик и психолог с 50 летним опытом, работающий с малым и средним бизнесом в Казахстане.\n\n");
promptBuilder.append(
"Твоя задача — на основе входных данных о бизнесе выполнить ГЛУБОКИЙ анализ целевой аудитории с психологическим профилированием сегментов.\n\n");
promptBuilder.append(
@@ -1831,15 +1719,10 @@ public class MarketingAnalysisService {
}
}
/**
* Генерация анализа конкурентов
*/
private Map<String, Object> getCompetitorAnalysis(MarketingAnalysisRequest request) {
try {
String context = buildContextString(request);
// Mandatory web search evidence (Serper.dev) to find REAL competitors in
// KZ/Almaty.
String niche = request != null ? request.getBusinessNiche() : null;
String product = request != null ? request.getProduct() : null;
List<String> regionsForSearch = request != null ? request.getRegion() : null;
@@ -2238,10 +2121,7 @@ public class MarketingAnalysisService {
return new HashMap<>();
}
/**
* Генерация анализа стратегии, воронки, каналов и SWOT
* Модульный метод для 4-го этапа генерации
*/
private Map<String, Object> getStrategyAndFunnel(MarketingAnalysisRequest request) {
try {
String context = buildContextString(request);
@@ -2702,30 +2582,13 @@ public class MarketingAnalysisService {
for (String p : noDataSentencePatterns) {
cleaned = cleaned.replaceAll(p, "Экстраполяция: применяем консервативные допущения и методику расчета.");
}
// 2) Remove lingering short phrases (in case model wrote it without
// punctuation)
cleaned = cleaned.replaceAll("(?i)\\b(нет\\s*данных|данные\\s*отсутствуют|нет\\s*информации)\\b", "");
// 3) Optionally remove any leftover placeholders
if (removePlaceholders) {
cleaned = cleaned.replaceAll("(?i)\\[\\[\\s*(CHART|TABLE)_[A-Za-z0-9_]+\\s*\\]\\]", "");
}
// Normalize whitespace
cleaned = cleaned.replaceAll("(\\r?\\n){3,}", "\n\n").trim();
return cleaned;
}
/**
* Удаляет «пустые» фразы-заглушки про графики, если рядом нет плейсхолдера
* [[CHART_...]] / [[TABLE_...]] или ```json:<key> блока.
*
* Мотивация: модель может написать «Анализ данных на графике ниже
* показывает...», но забыть
* вставить плейсхолдер. В результате в тексте остаётся пустая ссылка на «график
* ниже».
*/
private String removeDanglingChartNarration(String text) {
if (text == null || text.trim().isEmpty()) {
return text;