This commit is contained in:
root
2025-12-24 19:12:21 +05:00
parent efcfa1dc8f
commit 0a870ccb46
5 changed files with 509 additions and 15 deletions
@@ -0,0 +1,13 @@
package kz.konturai.parser.dto;
/**
* Normalized search result item extracted from Serper.dev response.
*/
public record SerperSearchItem(
String title,
String link,
String snippet,
String sourceHost) {
}
@@ -0,0 +1,15 @@
package kz.konturai.parser.dto;
import java.util.List;
/**
* Wrapper that we store in contextJson/prompts to make web evidence explicit and auditable.
*/
public record SerperSearchResult(
String query,
String status, // OK, ERROR, MISSING_API_KEY
String error,
List<SerperSearchItem> items) {
}
@@ -2,6 +2,8 @@ package kz.konturai.parser.service;
import kz.konturai.parser.dto.MarketingAnalysisRequest;
import kz.konturai.parser.dto.MarketingAnalysisResult;
import kz.konturai.parser.dto.SerperSearchItem;
import kz.konturai.parser.dto.SerperSearchResult;
import kz.konturai.parser.dto.StatusHistoryEntry;
import kz.konturai.parser.model.AnalysisType;
import kz.konturai.parser.model.DetailLevel;
@@ -95,6 +97,13 @@ public class MarketingAnalysisService {
promptBuilder.append("- Дай глубокий академический анализ с причинно-следственной логикой.\n");
promptBuilder
.append("- Используй терминологию (LTV, CAC, PESTEL, Porter's Five Forces) там, где уместно.\n");
if ("market".equalsIgnoreCase(sectionId) || "strategy".equalsIgnoreCase(sectionId)) {
promptBuilder.append(
"- ОБЯЗАТЕЛЬНО ВКЛЮЧИ ФРЕЙМВОРКИ: сделай отдельные подблоки PESTEL и «5 сил Портера» (не просто упоминание терминов).\n");
} else if ("competitors".equalsIgnoreCase(sectionId)) {
promptBuilder.append(
"- ОБЯЗАТЕЛЬНО ВКЛЮЧИ «5 сил Портера» (минимум: Rivalry/Threat of New Entrants/Substitutes/Buyer Power/Supplier Power) применительно к этой нише в Казахстане.\n");
}
promptBuilder.append("- После каждого крупного вывода добавляй конкретные тактические рекомендации.\n");
if (isKeyVerboseSectionForHybridRule(sectionId)) {
promptBuilder.append("- ОБЪЕМ (КЛЮЧЕВОЙ РАЗДЕЛ): не менее 500 слов.\n");
@@ -104,6 +113,66 @@ public class MarketingAnalysisService {
}
}
private Map<String, Object> buildResearchNotesForSection(Map<String, Object> contextJson, String sectionId) {
if (contextJson == null) {
return new HashMap<>();
}
try {
String jsonString = objectMapper.writeValueAsString(contextJson);
StringBuilder instruction = new StringBuilder();
instruction.append("Ты — аналитик-исследователь по рынку Казахстана.\n");
instruction.append(
"На основе JSON (особенно researchPack, market, seasonality, competitorAnalysis) сделай КОНСПЕКТ-ЗАМЕТКИ.\n");
instruction.append("Верни ТОЛЬКО валидный JSON без markdown/комментариев.\n\n");
instruction.append("СТРУКТУРА JSON (строго):\n");
instruction.append("{\n");
instruction.append(" \"sectionId\": \"").append(sectionId).append("\",\n");
instruction.append(" \"macro\": {\n");
instruction.append(
" \"inflation\": \"Коротко: что происходит с инфляцией в РК (2024–2025) и как это влияет на нишу\",\n");
instruction
.append(" \"kzt\": \"Коротко: курс тенге/волатильность и влияние на себестоимость/импорт\",\n");
instruction.append(" \"logistics\": \"Коротко: логистические/импортные риски, если релевантно нише\"\n");
instruction.append(" },\n");
instruction.append(" \"pestel\": {\n");
instruction.append(
" \"P\": [\"...\"], \"E\": [\"...\"], \"S\": [\"...\"], \"T\": [\"...\"], \"E2\": [\"...\"], \"L\": [\"...\"]\n");
instruction.append(" },\n");
instruction.append(" \"porter5\": {\n");
instruction.append(" \"rivalry\": \"...\",\n");
instruction.append(" \"newEntrants\": \"...\",\n");
instruction.append(" \"substitutes\": \"...\",\n");
instruction.append(" \"buyerPower\": \"...\",\n");
instruction.append(" \"supplierPower\": \"...\"\n");
instruction.append(" },\n");
instruction.append(
" \"citations\": [ { \"title\": \"Источник\", \"url\": \"https://...\", \"year\": \"2024/2025\" } ]\n");
instruction.append("}\n\n");
instruction.append("ПРАВИЛА:\n");
instruction.append("- Если в researchPack есть ссылки — используй их в citations.\n");
instruction.append(
"- Если данных нет — пометь соответствующие поля как \"Экстраполяция\" и не выдавай это за факт.\n");
String resp = openAIAnalyticsService.generateWithInstructionWithModel(
jsonString,
instruction.toString(),
"ru",
miniModelName,
null,
2500);
if (resp == null || resp.isBlank()) {
return new HashMap<>();
}
String cleaned = cleanJsonFromMarkdown(resp);
return objectMapper.readValue(cleaned, new TypeReference<Map<String, Object>>() {
});
} catch (Exception e) {
logger.warn("buildResearchNotesForSection: failed for section {}: {}", sectionId, e.getMessage());
return new HashMap<>();
}
}
private void appendMandatoryJsonUsageWarning(StringBuilder promptBuilder) {
if (promptBuilder != null) {
promptBuilder.append(AI_MANDATORY_JSON_USAGE_WARNING);
@@ -292,6 +361,7 @@ public class MarketingAnalysisService {
private final OpenAIAnalyticsService openAIAnalyticsService;
private final MinIOService minIOService;
private final ResearchPdfService researchPdfService;
private final SerperSearchService serperSearchService;
private final ObjectMapper objectMapper = new ObjectMapper();
/**
@@ -316,11 +386,115 @@ public class MarketingAnalysisService {
MarketingAnalysisRepository repository,
OpenAIAnalyticsService openAIAnalyticsService,
MinIOService minIOService,
ResearchPdfService researchPdfService) {
ResearchPdfService researchPdfService,
SerperSearchService serperSearchService) {
this.repository = repository;
this.openAIAnalyticsService = openAIAnalyticsService;
this.minIOService = minIOService;
this.researchPdfService = researchPdfService;
this.serperSearchService = serperSearchService;
}
private boolean shouldIncludeResearchPackInSection(String section) {
if (section == null) {
return false;
}
return "summary".equalsIgnoreCase(section)
|| "product".equalsIgnoreCase(section)
|| "market".equalsIgnoreCase(section)
|| "competitors".equalsIgnoreCase(section)
|| "strategy".equalsIgnoreCase(section);
}
/**
* Hybrid web search strategy:
* - one general "research pack" per report
* - plus section-specific followups (III/V/XI are later enriched via prompts)
*/
private Map<String, Object> buildResearchPack(MarketingAnalysisRequest request) {
Map<String, Object> pack = new LinkedHashMap<>();
pack.put("generatedAt", LocalDateTime.now().toString());
pack.put("engine", "serper.dev");
String niche = request != null ? request.getBusinessNiche() : null;
String product = request != null ? request.getProduct() : null;
List<String> regions = request != null ? request.getRegion() : null;
String regionStr = (regions != null && !regions.isEmpty()) ? String.join(", ", regions) : "Казахстан";
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)";
List<Object> general = new ArrayList<>();
general.add(serperSearchService
.search(topic + " рынок " + regionStr + " 2024 2025 статистика объем рост " + preferredSites));
general.add(serperSearchService
.search("инфляция Казахстан 2024 2025 курс тенге 2024 2025 логистика импорт " + preferredSites));
// Lightweight section followups (stored for auditing/citations). Prompts for
// III/V/XI may further use these.
Map<String, Object> followups = new LinkedHashMap<>();
followups.put("market",
serperSearchService.search(topic + " объем рынка Казахстан 2024 2025 отчет " + preferredSites));
followups.put("competitors",
serperSearchService.search(topic + " конкуренты " + regionStr + " топ компании магазины"));
followups.put("finance",
serperSearchService.search(
topic + " себестоимость логистика импорт курс тенге влияние 2024 2025 " + preferredSites));
pack.put("general", general);
pack.put("followups", followups);
// Overall status
String status = "OK";
for (Object o : general) {
if (o instanceof kz.konturai.parser.dto.SerperSearchResult r) {
if (!"OK".equalsIgnoreCase(r.status())) {
status = r.status();
break;
}
}
}
pack.put("status", status);
return pack;
}
private void appendSerperEvidenceToPrompt(StringBuilder promptBuilder, String title, SerperSearchResult result,
int limit) {
if (promptBuilder == null) {
return;
}
promptBuilder.append(title).append(":\n");
if (result == null) {
promptBuilder.append("- (no result)\n\n");
return;
}
promptBuilder.append("- query: ").append(result.query()).append("\n");
promptBuilder.append("- status: ").append(result.status()).append("\n");
if (result.error() != null && !result.error().isBlank()) {
promptBuilder.append("- error: ").append(result.error()).append("\n");
}
List<SerperSearchItem> items = result.items() != null ? result.items() : List.of();
int n = Math.min(Math.max(limit, 0), items.size());
for (int i = 0; i < n; i++) {
SerperSearchItem it = items.get(i);
promptBuilder.append(" ").append(i + 1).append(") ");
if (it.title() != null) {
promptBuilder.append(it.title());
}
if (it.sourceHost() != null) {
promptBuilder.append(" [").append(it.sourceHost()).append("]");
}
promptBuilder.append("\n");
if (it.link() != null) {
promptBuilder.append(" url: ").append(it.link()).append("\n");
}
if (it.snippet() != null) {
promptBuilder.append(" snippet: ").append(it.snippet()).append("\n");
}
}
promptBuilder.append("\n");
}
@PreDestroy
@@ -813,17 +987,43 @@ public class MarketingAnalysisService {
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;
String regionStrForSearch = (regionsForSearch != null && !regionsForSearch.isEmpty())
? String.join(", ", regionsForSearch)
: "Казахстан";
String topic = (niche != null && !niche.isBlank()) ? niche : (product != null ? product : "ниша");
String preferredSites = "(site:kapital.kz OR site:kursiv.media OR site:forbes.kz)";
SerperSearchResult competitorsEvidence = serperSearchService.search(
topic + " конкуренты " + regionStrForSearch + " Алматы Казахстан топ компании магазины");
SerperSearchResult competitorsEvidencePreferred = serperSearchService.search(
topic + " рынок " + regionStrForSearch + " лидеры ритейла компании " + preferredSites);
StringBuilder promptBuilder = new StringBuilder();
promptBuilder.append(
"Ты — профессиональный маркетинговый аналитик, работающий с малым и средним бизнесом в Казахстане.\n\n");
promptBuilder.append(
"Твоя задача — на основе входных данных о бизнесе выполнить глубокий анализ конкурентов и выдать результат ТОЛЬКО в виде корректного JSON-объекта строго по указанной структуре.\n\n");
promptBuilder.append(
"КРИТИЧЕСКИ ВАЖНО: Найди или смоделируй реалистичных конкурентов для этой ниши. "
+ "Оцени их цифровые метрики и контент-стратегию. Если ты не знаешь реальных конкурентов, "
+ "создай реалистичные модели на основе типичных игроков в этой нише в Казахстане.\n\n");
"КРИТИЧЕСКИ ВАЖНО: Ниже даны результаты веб-поиска (Serper.dev). СНАЧАЛА проанализируй их, "
+ "ЗАТЕМ сформируй JSON. Если WEB_SEARCH_STATUS=OK, используй ТОЛЬКО реальные названия конкурентов "
+ "из WEB_SEARCH_RESULTS (никаких выдуманных брендов). Для каждого конкурента привяжи хотя бы 1 ссылку-источник.\n"
+ "Если веб-поиск недоступен, можешь дать модели конкурентов, но ОБЯЗАТЕЛЬНО пометь это как \"Экстраполяция\" "
+ "и не выдавай их за реальные компании.\n\n");
promptBuilder.append("Не добавляй пояснений, комментариев, текста до или после JSON.\n");
promptBuilder.append("Ответ должен быть валидным JSON.\n\n");
promptBuilder.append("WEB_SEARCH_RESULTS (для реальных конкурентов и источников):\n");
promptBuilder.append("WEB_SEARCH_STATUS: competitors=")
.append(competitorsEvidence != null ? competitorsEvidence.status() : "ERROR")
.append(", preferred=")
.append(competitorsEvidencePreferred != null ? competitorsEvidencePreferred.status() : "ERROR")
.append("\n\n");
appendSerperEvidenceToPrompt(promptBuilder, "COMPETITORS_EVIDENCE", competitorsEvidence, 10);
appendSerperEvidenceToPrompt(promptBuilder, "COMPETITORS_EVIDENCE_PREFERRED", competitorsEvidencePreferred,
6);
promptBuilder.append("---\n\n");
promptBuilder.append("ВХОДНЫЕ ДАННЫЕ ПОЛЬЗОВАТЕЛЯ:\n\n");
promptBuilder.append("- Название компании/продукта: ").append(request.getProduct()).append("\n");
@@ -887,7 +1087,9 @@ public class MarketingAnalysisService {
" { \"feature\": \"Позиционирование\", \"us\": \"...\", \"compA\": \"...\", \"compB\": \"...\" }\n");
promptBuilder.append(" ],\n");
promptBuilder.append(
" \"keyInsights\": [\"Глубокий инсайт 1 (1-2 предложения)\", \"Глубокий инсайт 2 (1-2 предложения)\"]\n");
" \"keyInsights\": [\"Глубокий инсайт 1 (1-2 предложения)\", \"Глубокий инсайт 2 (1-2 предложения)\"],\n");
promptBuilder.append(
" \"sources\": [ { \"title\": \"Источник (кратко)\", \"url\": \"https://...\", \"year\": \"2024/2025\" } ]\n");
promptBuilder.append(" }\n");
promptBuilder.append("}\n\n");
promptBuilder.append("КРИТИЧЕСКИ ВАЖНЫЕ ТРЕБОВАНИЯ:\n\n");
@@ -908,6 +1110,8 @@ public class MarketingAnalysisService {
promptBuilder.append("11. Все строки — на русском языке.\n");
promptBuilder.append(
"12. channelsHeatmap должна содержать оценки (0-100) для всех основных каналов: Instagram, TikTok, Telegram, YouTube, Facebook.\n");
promptBuilder.append(
"13. sources должен содержать реальные ссылки из WEB_SEARCH_RESULTS (если WEB_SEARCH_STATUS=OK). Если веб-поиск недоступен — sources можно оставить пустым, а выводы пометь как \"Экстраполяция\".\n");
String prompt = promptBuilder.toString();
logger.debug(
@@ -945,6 +1149,20 @@ public class MarketingAnalysisService {
private Map<String, Object> getSeasonalityAndMarket(MarketingAnalysisRequest request) {
String context = buildContextString(request);
// Mandatory web search evidence (Serper.dev) for 2024-2025 market data
String niche = request != null ? request.getBusinessNiche() : null;
String product = request != null ? request.getProduct() : null;
List<String> regionsForSearch = request != null ? request.getRegion() : null;
String regionStrForSearch = (regionsForSearch != null && !regionsForSearch.isEmpty())
? String.join(", ", regionsForSearch)
: "Казахстан";
String topic = (niche != null && !niche.isBlank()) ? niche : (product != null ? product : "ниша");
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)";
SerperSearchResult marketEvidence = serperSearchService.search(
topic + " рынок " + regionStrForSearch + " 2024 2025 статистика объем рост " + preferredSites);
SerperSearchResult seasonalityEvidence = serperSearchService.search(
topic + " сезонность спроса " + regionStrForSearch + " 2024 2025 " + preferredSites);
StringBuilder promptBuilder = new StringBuilder();
promptBuilder.append(
"Ты — профессиональный маркетинговый аналитик, работающий с малым и средним бизнесом в Казахстане.\n\n");
@@ -953,8 +1171,22 @@ public class MarketingAnalysisService {
promptBuilder.append(
"КРИТИЧЕСКИ ВАЖНО: Предоставь детальный анализ сезонных колебаний спроса с конкретными объяснениями. "
+ "Опиши рыночные тренды, возможности и угрозы с конкретными инсайтами, а не общими фразами.\n\n");
promptBuilder.append(
"ОБЯЗАТЕЛЬНО (WEB SEARCH): ниже тебе даны результаты веб-поиска (Serper.dev) по 20242025 годам. "
+ "СНАЧАЛА проанализируй эти данные, а ЗАТЕМ сформируй JSON. "
+ "Если данные содержат числа — используй их. Если точных чисел нет — делай экспертную оценку через смежные рынки/ВВП/покупательную способность и помечай это как \"Экстраполяция\".\n");
promptBuilder.append(
"ЗАПРЕТ: нельзя использовать фиксированные заглушки или одинаковые оценки для разных ниш. Обувь не может стоить столько же, сколько видеокарты.\n\n");
promptBuilder.append("Не добавляй пояснений, комментариев, текста до или после JSON.\n");
promptBuilder.append("Ответ должен быть валидным JSON.\n\n");
promptBuilder.append("WEB_SEARCH_RESULTS (используй как доказательства и источники):\n");
promptBuilder.append("WEB_SEARCH_STATUS: market=")
.append(marketEvidence != null ? marketEvidence.status() : "ERROR")
.append(", seasonality=")
.append(seasonalityEvidence != null ? seasonalityEvidence.status() : "ERROR")
.append("\n\n");
appendSerperEvidenceToPrompt(promptBuilder, "MARKET_EVIDENCE", marketEvidence, 8);
appendSerperEvidenceToPrompt(promptBuilder, "SEASONALITY_EVIDENCE", seasonalityEvidence, 6);
promptBuilder.append("---\n\n");
promptBuilder.append("ВХОДНЫЕ ДАННЫЕ ПОЛЬЗОВАТЕЛЯ:\n\n");
promptBuilder.append("- Название компании/продукта: ").append(request.getProduct()).append("\n");
@@ -984,7 +1216,11 @@ public class MarketingAnalysisService {
" \"marketSize\": \"Оценка объема рынка (например, 'Оценка объема рынка составляет X млн тенге в год' или 'Средний рынок с потенциалом роста'). "
+ "Дай конкретную оценку или описание масштаба рынка в Казахстане для данной ниши.\",\n");
promptBuilder.append(
" \"growthRate\": \"Темп роста рынка (например, '+15% в год' или 'Стабильный рост 10-12% ежегодно')\",\n");
" \"growthRate\": \"Темп роста рынка (например, 'Умеренный рост X% в год' или 'Стабильный рост на уровне инфляции'). Укажи оценку/диапазон и кратко обоснуй.\",\n");
promptBuilder.append(
" \"sources\": [ { \"title\": \"Источник (кратко)\", \"url\": \"https://...\", \"year\": \"2024/2025\" } ],\n");
promptBuilder.append(
" \"extrapolationMethod\": \"Если точных данных нет, опиши метод экспертной оценки и пометь как Экстраполяция.\",\n");
promptBuilder.append(
" \"trends\": [\"Конкретный тренд 1 (1-2 предложения с деталями)\", \"Конкретный тренд 2 (1-2 предложения с деталями)\", \"Конкретный тренд 3 (1-2 предложения с деталями)\"],\n");
promptBuilder.append(
@@ -1013,6 +1249,8 @@ public class MarketingAnalysisService {
promptBuilder.append("10. Все строки — на русском языке.\n");
promptBuilder.append(
"11. НЕ включай поле \"estimated\" ни в seasonality, ни в market.\n");
promptBuilder.append(
"12. sources должен содержать реальные ссылки из WEB_SEARCH_RESULTS (если WEB_SEARCH_STATUS=OK). Если веб-поиск недоступен — укажи extrapolationMethod как \"Экстраполяция\" и оставь sources пустым массивом.\n");
String prompt = promptBuilder.toString();
logger.debug(
@@ -1573,6 +1811,9 @@ public class MarketingAnalysisService {
if (fullJson.containsKey("detailLevel")) {
context.put("detailLevel", fullJson.get("detailLevel"));
}
if (fullJson.containsKey("researchPack") && shouldIncludeResearchPackInSection(section)) {
context.put("researchPack", fullJson.get("researchPack"));
}
// Добавляем специфичные данные для каждого раздела
switch (section) {
@@ -1581,6 +1822,14 @@ public class MarketingAnalysisService {
if (fullJson.containsKey("meta")) {
context.put("meta", fullJson.get("meta"));
}
// Резюме обязано опираться на рынок/сезонность (динамические значения +
// источники)
if (fullJson.containsKey("seasonality")) {
context.put("seasonality", fullJson.get("seasonality"));
}
if (fullJson.containsKey("market")) {
context.put("market", fullJson.get("market"));
}
break;
case "product":
// Для продукта добавляем позиционирование и УТП если есть
@@ -1590,6 +1839,10 @@ public class MarketingAnalysisService {
if (fullJson.containsKey("valueProposition")) {
context.put("valueProposition", fullJson.get("valueProposition"));
}
// Раздел продукта также должен корректно ссылаться на рынок (без заглушек)
if (fullJson.containsKey("market")) {
context.put("market", fullJson.get("market"));
}
break;
case "market":
// ОБЯЗАТЕЛЬНО: seasonality + market должны доходить до секции рынка
@@ -1851,9 +2104,9 @@ public class MarketingAnalysisService {
promptBuilder.append("3. Глубина: аналитические инсайты с практическими выводами\n");
promptBuilder.append("4. Данные: используй ТОЛЬКО данные из JSON, не выдумывай цифры\n");
promptBuilder.append(
"5. ОБЯЗАТЕЛЬНЫЕ ЦИФРЫ: в тексте обязательно упомяни, что объем рынка = 15 млрд тенге, а темпы роста = 12%.\n");
"5. Рынок: обязательно упомяни оценку объема рынка и темпы роста ТОЛЬКО из JSON (market.marketSize и market.growthRate).\n");
promptBuilder.append(
" Эти цифры должны быть явно написаны в тексте (как минимум 1 раз) и использованы в выводах.\n\n");
" Запрещено использовать фиксированные «заглушки» или одинаковые числа для разных ниш. Если точных данных нет — делай аналитическую экстраполяцию и помечай как \"Экстраполяция\".\n\n");
promptBuilder.append("СТРУКТУРА РАЗДЕЛА:\n");
promptBuilder.append("I. Краткое резюме\n\n");
promptBuilder.append("Опиши:\n");
@@ -1907,7 +2160,7 @@ public class MarketingAnalysisService {
promptBuilder.append("3. Глубина: аналитические инсайты с практическими выводами\n");
promptBuilder.append("4. Данные: используй ТОЛЬКО данные из JSON, не выдумывай цифры\n");
promptBuilder.append(
"5. Обязательно назови объем рынка (15 млрд тенге) и темпы роста (12%).\n");
"5. Обязательно назови объем рынка и темпы роста ТОЛЬКО из JSON (market.marketSize и market.growthRate), с краткой ссылкой на источник, если он указан в researchPack/market.sources.\n");
promptBuilder.append(
"6. Объясни, что эти цифры значат для окупаемости фитнес-центра (логика окупаемости/скорость набора клиентов/масштаб спроса),\n");
promptBuilder.append(
@@ -1952,6 +2205,13 @@ public class MarketingAnalysisService {
*/
private String genSectionMarket(Map<String, Object> contextJson) {
try {
DetailLevel detailLevel = resolveDetailLevelFromContext(contextJson);
if (detailLevel == DetailLevel.ПОДРОБНО) {
Map<String, Object> notes = buildResearchNotesForSection(contextJson, "market");
if (notes != null && !notes.isEmpty()) {
contextJson.put("researchNotes", Map.of("market", notes));
}
}
String jsonString = objectMapper.writeValueAsString(contextJson);
StringBuilder promptBuilder = new StringBuilder();
promptBuilder.append("Ты — высокооплачиваемый бизнес-консультант и маркетинговый аналитик.\n\n");
@@ -1967,6 +2227,10 @@ public class MarketingAnalysisService {
promptBuilder.append("2. Стиль: деловой, убедительный для владельца бизнеса\n");
promptBuilder.append("3. Глубина: аналитические инсайты с практическими выводами\n");
promptBuilder.append("4. Данные: используй ТОЛЬКО данные из JSON, не выдумывай цифры\n\n");
promptBuilder.append(
"5. Контроль галлюцинаций: если используешь данные из researchPack/market.sources/researchNotes.citations — добавляй в тексте краткую ссылку (в скобках).\n");
promptBuilder.append(
"6. Если данных нет — используй аналитическую экстраполяцию и явно помечай блок как \"Экстраполяция\".\n\n");
promptBuilder.append("ОБЯЗАТЕЛЬНАЯ СТРОКА:\n");
promptBuilder.append(
"- Обязательно вставь тег [[CHART_SEASONALITY]] непосредственно рядом с описанием сезонности (внутри абзаца или сразу после него).\n\n");
@@ -2002,7 +2266,6 @@ public class MarketingAnalysisService {
promptBuilder.append("- Пиши максимально подробно, анализируй тренды\n");
promptBuilder.append(
"- Каждое ключевое утверждение подтверждай конкретной цифрой из JSON; если нужна связующая метрика — делай экстраполяцию и помечай как \"Экстраполяция\".\n\n");
DetailLevel detailLevel = resolveDetailLevelFromContext(contextJson);
appendDetailLevelDirectives(promptBuilder, detailLevel, "market");
promptBuilder.append("JSON ДАННЫЕ:\n");
promptBuilder.append(jsonString);
@@ -2144,6 +2407,13 @@ public class MarketingAnalysisService {
*/
private String genSectionCompetitors(Map<String, Object> contextJson) {
try {
DetailLevel detailLevel = resolveDetailLevelFromContext(contextJson);
if (detailLevel == DetailLevel.ПОДРОБНО) {
Map<String, Object> notes = buildResearchNotesForSection(contextJson, "competitors");
if (notes != null && !notes.isEmpty()) {
contextJson.put("researchNotes", Map.of("competitors", notes));
}
}
String jsonString = objectMapper.writeValueAsString(contextJson);
StringBuilder promptBuilder = new StringBuilder();
promptBuilder.append("Ты — высокооплачиваемый бизнес-консультант и маркетинговый аналитик.\n\n");
@@ -2166,6 +2436,8 @@ public class MarketingAnalysisService {
promptBuilder.append("4. Данные: используй ТОЛЬКО данные из JSON, не выдумывай цифры\n");
promptBuilder.append(
"5. Это должен быть самый мощный раздел отчёта: каждое утверждение подтверждай цифрами из competitorAnalysis.marketShareChart / competitorAnalysis.comparisonTable.\n\n");
promptBuilder.append(
"6. Контроль галлюцинаций: если используешь данные из competitorAnalysis.sources/researchPack/researchNotes.citations — добавляй в тексте краткую ссылку (в скобках).\n");
promptBuilder.append("ОБЯЗАТЕЛЬНЫЕ СТРОКИ:\n");
promptBuilder.append(
"- Обязательно вставь теги [[CHART_MARKET_SHARE]] и [[TABLE_COMPARISON]] рядом с теми утверждениями, где ты обсуждаешь конкретные цифры/сравнения (внутри абзаца или сразу после него).\n\n");
@@ -2223,7 +2495,6 @@ public class MarketingAnalysisService {
"- Используй данные из competitorAnalysis.marketShareChart и competitorAnalysis.comparisonTable\n");
promptBuilder.append(
"- Не выдумывай цифры. Если в конкретной строке/метрике нет явного значения — делай консервативную экстраполяцию и помечай как \"Экстраполяция\" (без фраз про отсутствие данных).\n\n");
DetailLevel detailLevel = resolveDetailLevelFromContext(contextJson);
appendDetailLevelDirectives(promptBuilder, detailLevel, "competitors");
promptBuilder.append("JSON ДАННЫЕ:\n");
promptBuilder.append(jsonString);
@@ -2610,6 +2881,13 @@ public class MarketingAnalysisService {
*/
private String genSectionStrategy(Map<String, Object> contextJson) {
try {
DetailLevel detailLevel = resolveDetailLevelFromContext(contextJson);
if (detailLevel == DetailLevel.ПОДРОБНО) {
Map<String, Object> notes = buildResearchNotesForSection(contextJson, "strategy");
if (notes != null && !notes.isEmpty()) {
contextJson.put("researchNotes", Map.of("strategy", notes));
}
}
String jsonString = objectMapper.writeValueAsString(contextJson);
StringBuilder promptBuilder = new StringBuilder();
promptBuilder.append("Ты — высокооплачиваемый бизнес-консультант и маркетинговый аналитик.\n\n");
@@ -2639,6 +2917,15 @@ public class MarketingAnalysisService {
promptBuilder.append("- Все формулы в этом разделе пиши ТОЛЬКО в LaTeX.\n");
promptBuilder.append(
"- Обязательно вставь фразу: \"Вероятная прибыль рассчитывается по формуле: $$ P = V \\times CR \\times AvgCheck $$\".\n\n");
promptBuilder.append("UNIT-ЭКОНОМИКА (ОБЯЗАТЕЛЬНО, LaTeX):\n");
promptBuilder.append(
"- CAC: \\[ CAC = \\frac{Marketing\\ Spend + Sales\\ Spend}{New\\ Customers} \\].\n");
promptBuilder.append(
"- LTV: \\[ LTV = AvgCheck \\times Frequency \\times GrossMargin \\times Lifetime \\].\n");
promptBuilder.append(
"- ROI: \\[ ROI = \\frac{Profit - Investment}{Investment} \\times 100\\% \\].\n");
promptBuilder.append(
"- Подставь числа из JSON (если есть). Если чисел нет — сделай реалистичные допущения и пометь блок как \"Экстраполяция\".\n\n");
promptBuilder.append(
"1. Возьми воронку из strategyAnalysis.funnel (или из funnel, если strategyAnalysis отсутствует) и выполни расчеты.\n");
promptBuilder.append(
@@ -2659,13 +2946,16 @@ public class MarketingAnalysisService {
"- Используй данные из strategyAnalysis (и/или swot/channels/funnel при наличии), positioning, valueProposition, contentRecommendations\n");
promptBuilder.append("- Давай конкретный, практический план действий с приоритетами\n");
promptBuilder.append("- Учитывай цель бизнеса на 6-12 месяцев (goal)\n\n");
DetailLevel detailLevel = resolveDetailLevelFromContext(contextJson);
if (detailLevel == DetailLevel.ПОДРОБНО) {
promptBuilder.append("ДОПОЛНИТЕЛЬНО ДЛЯ ПОДРОБНОГО ОТЧЕТА (ОБЯЗАТЕЛЬНО):\n");
promptBuilder.append(
"- Составь помесячный план на все 12 месяцев (логика Gantt): перечисли 58 workstreams (например: продукт/оффер, перформанс, контент, CRM, продажи, партнерства, аналитика), и для каждого укажи активные месяцы (1..12), зависимости, контрольные точки и KPI.\n");
promptBuilder.append(
"- Представь Gantt-логику в виде компактной таблицы (workstream × месяцы 1..12) + краткие пояснения по зависимостям.\n");
promptBuilder.append(
"- Макро: обязательно учти инфляцию в РК, курс тенге и логистические риски (используй researchPack/researchNotes). Если есть источник — добавь короткую ссылку.\n");
promptBuilder.append(
"- Фреймворки: обязательно включи PESTEL и 5 сил Портера (как отдельные подблоки) и привяжи выводы к практическим действиям.\n\n");
promptBuilder.append("BREAK-EVEN (ОБЯЗАТЕЛЬНО):\n");
promptBuilder.append(
"- Рассчитай точку безубыточности (Break-even point) строго по формуле в LaTeX: \\[ BEP = \\frac{Fixed\\ Costs}{Price - Variable\\ Costs} \\].\n");
@@ -2673,11 +2963,14 @@ public class MarketingAnalysisService {
"- Если в JSON нет нужных чисел: введи реалистичные допущения (пометь как \"Экстраполяция\") и сделай 3 сценария (консервативный/базовый/оптимистичный), затем интерпретируй управленчески.\n\n");
}
appendDetailLevelDirectives(promptBuilder, detailLevel, "strategy");
promptBuilder.append(
"КОНТРОЛЬ ГАЛЛЮЦИНАЦИЙ:\n- Если используешь данные из researchPack/market.sources/competitorAnalysis.sources/researchNotes.citations — добавляй в тексте краткую ссылку (в скобках).\n"
+ "- Если данных нет — используй аналитическую экстраполяцию и явно помечай блок как \"Экстраполяция\".\n\n");
promptBuilder.append("JSON ДАННЫЕ:\n");
promptBuilder.append(jsonString);
String result = generateWithRetryForSection(contextJson, jsonString, promptBuilder.toString(), "ru",
miniModelName, null, 1, "strategy");
textModelName, null, 2, "strategy");
if (!isValidAiResponse(result)) {
logger.warn("genSectionStrategy: AI returned invalid or refusal response");
@@ -3411,6 +3704,10 @@ public class MarketingAnalysisService {
logger.info("generateMarketingReport: Starting report generation for product: {}", request.getProduct());
Map<String, Object> report = new HashMap<>();
// Web search is mandatory before generating any report parts.
Map<String, Object> researchPack = buildResearchPack(request);
report.put("researchPack", researchPack);
// Get all analysis types and detail level
List<AnalysisType> analysisTypes = request.getAnalysisTypeEnums();
if (analysisTypes == null || analysisTypes.isEmpty()) {
@@ -3453,9 +3750,14 @@ public class MarketingAnalysisService {
analysisJson.put("goal", request.getGoal());
analysisJson.put("regions", request.getRegion());
analysisJson.put("detailLevel", detailLevelName);
analysisJson.put("researchPack", researchPack);
// Сохраняем JSON в отчёт
report.put("analysisJson", analysisJson);
// Make key market fields available at top-level for markdown/table rendering
if (analysisJson.get("market") != null) {
report.put("market", analysisJson.get("market"));
}
// Шаг 2: Генерация профессионального текстового отчёта (11 параллельных вызовов
// AI)
@@ -3870,8 +4172,28 @@ public class MarketingAnalysisService {
markdown.append("| Продукт | ").append(productStr).append(" |\n");
markdown.append("| Регион | ").append(regionStr).append(" |\n");
markdown.append("| Цель | ").append(goalStr).append(" |\n");
markdown.append("| Объем рынка | 15 млрд |\n");
markdown.append("| Темп роста | 12% |\n\n");
String marketSizeStr = "-";
String growthRateStr = "-";
try {
@SuppressWarnings("unchecked")
Map<String, Object> market = reportData != null && reportData.get("market") instanceof Map
? (Map<String, Object>) reportData.get("market")
: null;
if (market != null) {
Object ms = market.get("marketSize");
Object gr = market.get("growthRate");
if (ms != null && !String.valueOf(ms).trim().isEmpty()) {
marketSizeStr = String.valueOf(ms).trim();
}
if (gr != null && !String.valueOf(gr).trim().isEmpty()) {
growthRateStr = String.valueOf(gr).trim();
}
}
} catch (Exception ignore) {
// Keep '-' if parsing fails
}
markdown.append("| Объем рынка | ").append(marketSizeStr).append(" |\n");
markdown.append("| Темп роста | ").append(growthRateStr).append(" |\n\n");
// Display full analysis - это основной блок с полным отчетом
// Все секции (I-XI) уже включены в fullAnalysis благодаря модульной генерации
@@ -0,0 +1,139 @@
package kz.konturai.parser.service;
import kz.konturai.parser.dto.SerperSearchItem;
import kz.konturai.parser.dto.SerperSearchResult;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Service;
import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.reactive.function.client.WebClientResponseException;
import reactor.core.publisher.Mono;
import java.net.URI;
import java.time.Duration;
import java.util.*;
@Service
public class SerperSearchService {
private static final Logger log = LoggerFactory.getLogger(SerperSearchService.class);
private final WebClient webClient;
@Value("${serper.api.key:}")
private String apiKey;
@Value("${serper.api.url:https://google.serper.dev}")
private String apiUrl;
@Value("${serper.timeoutMs:30000}")
private long timeoutMs;
public SerperSearchService() {
this.webClient = WebClient.builder()
.defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)
.build();
}
public SerperSearchResult search(String query) {
return search(query, 10, "kz", "ru");
}
public SerperSearchResult search(String query, int num, String gl, String hl) {
if (query == null || query.isBlank()) {
return new SerperSearchResult(query, "ERROR", "Empty query", List.of());
}
if (apiKey == null || apiKey.isBlank()) {
return new SerperSearchResult(query, "MISSING_API_KEY",
"Serper API key is not configured (serper.api.key is blank)", List.of());
}
Map<String, Object> body = new LinkedHashMap<>();
body.put("q", query);
body.put("num", Math.max(1, Math.min(num, 20)));
if (gl != null && !gl.isBlank()) {
body.put("gl", gl);
}
if (hl != null && !hl.isBlank()) {
body.put("hl", hl);
}
try {
Mono<Map> call = webClient.post()
.uri(apiUrl + "/search")
.header("X-API-KEY", apiKey)
.bodyValue(body)
.retrieve()
.bodyToMono(Map.class);
if (timeoutMs > 0) {
call = call.timeout(Duration.ofMillis(timeoutMs));
}
@SuppressWarnings("unchecked")
Map<String, Object> response = (Map<String, Object>) call.block();
List<SerperSearchItem> items = extractOrganicItems(response);
return new SerperSearchResult(query, "OK", null, items);
} catch (WebClientResponseException e) {
String msg = "Serper API error: HTTP " + e.getStatusCode().value() + " - " + e.getResponseBodyAsString();
log.warn(msg);
return new SerperSearchResult(query, "ERROR", msg, List.of());
} catch (Exception e) {
String msg = "Failed to call Serper API: " + e.getMessage();
log.warn(msg, e);
return new SerperSearchResult(query, "ERROR", msg, List.of());
}
}
@SuppressWarnings("unchecked")
private List<SerperSearchItem> extractOrganicItems(Map<String, Object> response) {
if (response == null || response.isEmpty()) {
return List.of();
}
Object organicObj = response.get("organic");
if (!(organicObj instanceof List<?> organicList)) {
return List.of();
}
List<SerperSearchItem> out = new ArrayList<>();
for (Object o : organicList) {
if (!(o instanceof Map<?, ?> m)) {
continue;
}
String title = str(m.get("title"));
String link = str(m.get("link"));
String snippet = str(m.get("snippet"));
String host = hostFromLink(link);
if (title == null && link == null && snippet == null) {
continue;
}
out.add(new SerperSearchItem(title, link, snippet, host));
}
return out;
}
private String str(Object v) {
if (v == null) {
return null;
}
String s = String.valueOf(v).trim();
return s.isEmpty() ? null : s;
}
private String hostFromLink(String link) {
if (link == null || link.isBlank()) {
return null;
}
try {
URI uri = URI.create(link);
return uri.getHost();
} catch (Exception ignore) {
return null;
}
}
}
@@ -70,6 +70,11 @@ openai.retry.maxDelayMs=60000
openai.retry.multiplier=2.0
openai.rateLimit.maxConcurrentRequests=2
# Serper.dev Web Search Configuration
serper.api.key=
serper.api.url=https://google.serper.dev
serper.timeoutMs=30000
# OpenAI DALL-E Image Generation Configuration
openai.image.model=dall-e-3
openai.image.size=1024x1024