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