This commit is contained in:
arys
2026-01-17 14:55:27 +05:00
parent b29c7377c9
commit e7c0a1e0d6
2 changed files with 73 additions and 68 deletions
@@ -135,16 +135,13 @@ public class MarketingController {
if (generateV2) {
try {
v2AnalysisId = marketingAnalysisService.generateAndSaveAnalysisV2(request, userId);
// Связываем обычный анализ с v2
analysis.setV2AnalysisId(v2AnalysisId);
marketingAnalysisService.updateAnalysis(analysis);
// Связываем v2 анализ с обычным
marketingAnalysisService.linkV2AnalysisToAnalysis(v2AnalysisId, analysis.getId());
logger.info("Created and linked v2 analysis {} to analysis {}", v2AnalysisId,
analysis.getId());
} catch (Exception e) {
logger.error("Error creating v2 analysis: {}", e.getMessage(), e);
// Продолжаем выполнение даже если v2 анализ не создался
}
}
@@ -33,6 +33,16 @@ import org.springframework.web.reactive.function.client.WebClientResponseExcepti
@Service
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";
private static final String AI_MANDATORY_JSON_USAGE_WARNING = "ВНИМАНИЕ: Если в предоставленном JSON есть данные, ты ОБЯЗАН использовать их и вставить соответствующий тег [[CHART_...]]. "
+ "Никогда не пиши, что данных нет, если JSON не пустой.\n\n";
private static final String AI_ULTIMATE_JSON_PRESENCE_INSTRUCTION = ""
@@ -465,21 +475,20 @@ public class MarketingAnalysisService {
// 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";
List<Object> general = new ArrayList<>();
String query1 = topic + " рынок " + regionStr + " 2024 2025 статистика объем рост " + preferredSites;
try {
general.add(serperSearchService.search(query1));
} catch (Exception e) {
// Handle DNS/network errors and other exceptions from Serper API
// Handle DNS/network errors... (keeping existing error handling)
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";
} else if (e instanceof java.util.concurrent.TimeoutException) {
errorType = "Timeout";
} else if (e instanceof java.io.IOException) {
errorType = "IO error";
}
else if (e instanceof java.util.concurrent.TimeoutException) errorType = "Timeout";
else if (e instanceof java.io.IOException) errorType = "IO error";
logger.warn("Serper API {} for query '{}': {}", errorType, query1, e.getMessage());
general.add(new SerperSearchResult(query1, "ERROR", errorType + ": " + e.getMessage(), List.of()));
}
@@ -488,74 +497,74 @@ public class MarketingAnalysisService {
try {
general.add(serperSearchService.search(query2));
} catch (Exception e) {
// Handle DNS/network errors and other exceptions from Serper API
// Keeping existing error handling
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";
} else if (e instanceof java.util.concurrent.TimeoutException) {
errorType = "Timeout";
} else if (e instanceof java.io.IOException) {
errorType = "IO error";
}
else if (e instanceof java.util.concurrent.TimeoutException) errorType = "Timeout";
else if (e instanceof java.io.IOException) errorType = "IO error";
logger.warn("Serper API {} for query '{}': {}", errorType, query2, e.getMessage());
general.add(new SerperSearchResult(query2, "ERROR", errorType + ": " + e.getMessage(), List.of()));
}
// Lightweight section followups (stored for auditing/citations). Prompts for
// III/V/XI may further use these.
// Lightweight section followups (stored for auditing/citations). Prompts for III/V/XI may further use these.
Map<String, Object> followups = new LinkedHashMap<>();
String marketQuery = topic + " объем рынка Казахстан 2024 2025 отчет " + preferredSites;
try {
followups.put("market", serperSearchService.search(marketQuery));
} catch (Exception e) {
// Handle DNS/network errors and other exceptions from Serper API
// Keeping existing error handling
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";
} else if (e instanceof java.util.concurrent.TimeoutException) {
errorType = "Timeout";
} else if (e instanceof java.io.IOException) {
errorType = "IO error";
}
else if (e instanceof java.util.concurrent.TimeoutException) errorType = "Timeout";
else if (e instanceof java.io.IOException) errorType = "IO error";
logger.warn("Serper API {} for market query '{}': {}", errorType, marketQuery, e.getMessage());
followups.put("market",
new SerperSearchResult(marketQuery, "ERROR", errorType + ": " + e.getMessage(), List.of()));
followups.put("market", new SerperSearchResult(marketQuery, "ERROR", errorType + ": " + e.getMessage(), List.of()));
}
String competitorsQuery = topic + " конкуренты " + regionStr + " топ компании магазины";
String competitorsQuery = topic + " конкуренты " + regionStr + " топ компании магазины цены";
try {
followups.put("competitors", serperSearchService.search(competitorsQuery));
} catch (Exception e) {
// Handle DNS/network errors and other exceptions from Serper API
// Keeping existing error handling
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";
} else if (e instanceof java.util.concurrent.TimeoutException) {
errorType = "Timeout";
} else if (e instanceof java.io.IOException) {
errorType = "IO error";
}
else if (e instanceof java.util.concurrent.TimeoutException) errorType = "Timeout";
else if (e instanceof java.io.IOException) errorType = "IO error";
logger.warn("Serper API {} for competitors query '{}': {}", errorType, competitorsQuery, e.getMessage());
followups.put("competitors",
new SerperSearchResult(competitorsQuery, "ERROR", errorType + ": " + e.getMessage(), List.of()));
followups.put("competitors", new SerperSearchResult(competitorsQuery, "ERROR", errorType + ": " + e.getMessage(), List.of()));
}
String financeQuery = topic + " себестоимость логистика импорт курс тенге влияние 2024 2025 " + preferredSites;
try {
followups.put("finance", serperSearchService.search(financeQuery));
} catch (Exception e) {
// Handle DNS/network errors and other exceptions from Serper API
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";
} else if (e instanceof java.util.concurrent.TimeoutException) {
errorType = "Timeout";
} else if (e instanceof java.io.IOException) {
errorType = "IO error";
}
else if (e instanceof java.util.concurrent.TimeoutException) errorType = "Timeout";
else if (e instanceof java.io.IOException) errorType = "IO error";
logger.warn("Serper API {} for finance query '{}': {}", errorType, financeQuery, e.getMessage());
followups.put("finance",
new SerperSearchResult(financeQuery, "ERROR", errorType + ": " + e.getMessage(), List.of()));
followups.put("finance", new SerperSearchResult(financeQuery, "ERROR", errorType + ": " + e.getMessage(), List.of()));
}
String incomeQuery = "средняя медианная зарплата по возрастам " + regionStr + " 2024 2025 статистика доходы населения бюро нацстатистики";
try {
followups.put("income", serperSearchService.search(incomeQuery));
} catch (Exception e) {
logger.warn("Serper API error for income query: {}", e.getMessage());
// Fail silently for supplementary metrics to avoid blocking main flow
followups.put("income", new SerperSearchResult(incomeQuery, "ERROR", e.getMessage(), List.of()));
}
String demandQuery = topic + " сезонность спроса динамика популярности " + regionStr + " когда покупают google trends";
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()));
}
pack.put("general", general);
@@ -656,11 +665,13 @@ public class MarketingAnalysisService {
buildAnalysisV2ExampleForPrompt(request, competitorCompanies));
String systemPrompt = ""
+ "Ты — партнер Big Four (управленческий консалтинг) и senior marketing analyst.\n"
+ "Пиши уверенно, структурно, с причинно-следственной логикой, без «воды».\n"
+ "КРИТИЧЕСКИ ВАЖНО: Твой ответ должен быть ТОЛЬКО валидный JSON, без markdown, без ```.\n";
+ "Ты — Партнер международной консалтинговой фирмы (Big 4) и ведущий маркетинговый стратег с 50-летним опытом.\n"
+ "Твоя специализация: Рынок Казахстана. Ты знаешь всё про тенге, Kaspi, менталитет и реальную инфляцию.\n"
+ "СТИЛЬ: Уверенный, профессиональный, структурный. Никакой воды. Только факты и обоснованные выводы.\n"
+ "КРИТИЧЕСКИ ВАЖНО: Твой ответ должен быть ТОЛЬКО валидный JSON, без markdown, без ```.\n"
+ "ЗАПРЕТ: Не выдумывай бренды (используй только найденные) и не генерируй случайные 'круглые' числа для графиков.\n";
// Извлекаем возрастные группы из запроса для инструкции
// Извлекаем возрастные группы из запроса для инструкции (старая логика сохранена)
String ageRangesInstruction = "";
Map<String, Object> targetAudienceData = request.getTargetAudience();
if (targetAudienceData != null && targetAudienceData.containsKey("ageRanges")) {
@@ -670,8 +681,7 @@ public class MarketingAnalysisService {
ageRangesInstruction = "\n"
+ "8) КРИТИЧЕСКИ ВАЖНО для target_audience.age_structure: Используй ТОЛЬКО те возрастные группы, которые указаны в запросе (request.targetAudience.ageRanges). "
+ "Указанные возрастные группы: " + String.join(", ", ageRanges) + ". "
+ "НЕ добавляй возрастные группы, которых нет в запросе. "
+ "Если в запросе указаны только \"25-34\" и \"35-44\", то в age_structure должны быть ТОЛЬКО эти две группы.\n";
+ "НЕ добавляй возрастные группы, которых нет в запросе.\n";
}
}
@@ -681,20 +691,19 @@ public class MarketingAnalysisService {
+ "1) Верни ТОЛЬКО JSON (без текста до/после).\n"
+ "2) Заполни ВСЕ вложенные поля: sections.market_overview, target_audience, competitive_environment, "
+ "demand_and_behavior, customer_journey, acquisition_channels, marketing_economics, bottlenecks, recommendations.\n"
+ "3) Используй реальные цифры/темпы роста/проценты из evidence (researchPack). Если конкретных цифр нет — сделай Analytical Extrapolation, "
+ "но НИКОГДА не пиши «данных нет/не найдено/недоступно».\n"
+ "4) Соблюдай типы:\n"
+ " - проценты/рейтинги/коэффициенты (форматы: +N%, A/B, Kx) => string\n"
+ " - monthly_online_index.* => number (double)\n"
+ " - funnel_metrics.leads/bookings/payments => integer\n"
+ " - market_overview.geography => object: ключ=город, значение=целое число (индекс интереса, 0..100)\n"
+ "3) Используй реальные цифры из evidence (researchPack). Если конкретных цифр нет — сделай Analytical Extrapolation, "
+ "но НИКОГДА не пиши «данных нет».\n"
+ "4) Соблюдай типы данных (числа - number/integer, проценты - string).\n"
+ "5) Пиши на русском языке.\n"
+ "6) competitive_environment.digital_visibility_benchmarks и competitive_environment.competitor_strengths: "
+ "ключи должны быть названиями РЕАЛЬНЫХ компаний/брендов (используй evidence.competitor_companies и evidence.followups.competitors). "
+ "Запрещены ключи-заглушки: competitor_a/competitor_b/competitor_c/your_business/lead/payment/others.\n"
+ "7) НИКОГДА не копируй значения из примера. В примере намеренно стоят плейсхолдеры, "
+ "которые начинаются с 'EXAMPLE__' и числа -9999/-9999.0 — в твоем ответе их быть НЕ должно."
+ "6) КОНКУРЕНТЫ: Используй РЕАЛЬНЫЕ названия из evidence.competitor_companies. Запрещены: competitor_a/b, your_business.\n"
+ "7) НИКОГДА не копируй значения из примера 'EXAMPLE__'.\n"
+ ageRangesInstruction
+ "\nКРИТИЧЕСКИЕ ИСПРАВЛЕНИЯ ПО ГРАФИКАМ (КАЗАХСТАН):\n"
+ "A. **ДИНАМИКА СПРОСА (market_overview.monthly_online_index)**: НЕ генерируй случайные числа! Подумай о сезонности ниши в РК. Если это 'Фитнес' - пик январь/сентябрь. Если 'Шины' - октябрь/апрель. График должен быть логичным, не плоским. (0.0 - 1.0)\n"
+ "B. **ГРАФИК ДОХОДОВ (target_audience.metrics)**: ОШИБКА - 'кривая доходности'. НУЖНО: Построить график 'Размер дохода (X) vs Возрастная категория (Y)'.\n"
+ " - В поле `income_by_age` (или аналогичном map) укажи средний доход в ТЕНГЕ (KZT) для каждой возрастной группы из `age_structure`.\n"
+ " - Используй данные из evidence.followups.income (Бюро нацстатистики/HH.kz). Пример: '25-34': 350000.\n"
+ "C. **ИСТОЧНИКИ**: В конце поля `recommendations.conclusion` или `market_overview.conclusion` ОБЯЗАТЕЛЬНО добавь список использованных источников (ссылки из evidence) в формате Markdown.\n"
+ "\nНиже пример правильной структуры (пример только для формы/полей; все значения должны быть заменены на значения из evidence):\n"
+ exampleJson;
@@ -722,14 +731,13 @@ public class MarketingAnalysisService {
+ " Верни ТОЛЬКО JSON.";
}
// Для V2 всегда используется gpt-4o (textModelName), независимо от detailLevel
String aiResponse = openAIAnalyticsService.generateWithInstructionWithModel(
contextJson,
attemptInstruction,
"ru",
textModelName, // gpt-4o - всегда для V2
textModelName,
systemPrompt,
15000,
16000,
180000L);
if (aiResponse == null || aiResponse.isBlank()) {