From 0a870ccb46557e509d407f83e27bcb73e8eec60c Mon Sep 17 00:00:00 2001 From: root Date: Wed, 24 Dec 2025 19:12:21 +0500 Subject: [PATCH] . --- .../konturai/parser/dto/SerperSearchItem.java | 13 + .../parser/dto/SerperSearchResult.java | 15 + .../service/MarketingAnalysisService.java | 352 +++++++++++++++++- .../parser/service/SerperSearchService.java | 139 +++++++ src/main/resources/application.properties | 5 + 5 files changed, 509 insertions(+), 15 deletions(-) create mode 100644 src/main/java/kz/konturai/parser/dto/SerperSearchItem.java create mode 100644 src/main/java/kz/konturai/parser/dto/SerperSearchResult.java create mode 100644 src/main/java/kz/konturai/parser/service/SerperSearchService.java diff --git a/src/main/java/kz/konturai/parser/dto/SerperSearchItem.java b/src/main/java/kz/konturai/parser/dto/SerperSearchItem.java new file mode 100644 index 0000000..0f71c42 --- /dev/null +++ b/src/main/java/kz/konturai/parser/dto/SerperSearchItem.java @@ -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) { +} + + diff --git a/src/main/java/kz/konturai/parser/dto/SerperSearchResult.java b/src/main/java/kz/konturai/parser/dto/SerperSearchResult.java new file mode 100644 index 0000000..eb26d05 --- /dev/null +++ b/src/main/java/kz/konturai/parser/dto/SerperSearchResult.java @@ -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 items) { +} + + diff --git a/src/main/java/kz/konturai/parser/service/MarketingAnalysisService.java b/src/main/java/kz/konturai/parser/service/MarketingAnalysisService.java index 1737e6c..33bcb6b 100644 --- a/src/main/java/kz/konturai/parser/service/MarketingAnalysisService.java +++ b/src/main/java/kz/konturai/parser/service/MarketingAnalysisService.java @@ -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 buildResearchNotesForSection(Map 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>() { + }); + } 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 buildResearchPack(MarketingAnalysisRequest request) { + Map 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 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 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 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 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 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 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 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) по 2024–2025 годам. " + + "СНАЧАЛА проанализируй эти данные, а ЗАТЕМ сформируй 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 contextJson) { try { + DetailLevel detailLevel = resolveDetailLevelFromContext(contextJson); + if (detailLevel == DetailLevel.ПОДРОБНО) { + Map 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 contextJson) { try { + DetailLevel detailLevel = resolveDetailLevelFromContext(contextJson); + if (detailLevel == DetailLevel.ПОДРОБНО) { + Map 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 contextJson) { try { + DetailLevel detailLevel = resolveDetailLevelFromContext(contextJson); + if (detailLevel == DetailLevel.ПОДРОБНО) { + Map 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): перечисли 5–8 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 report = new HashMap<>(); + // Web search is mandatory before generating any report parts. + Map researchPack = buildResearchPack(request); + report.put("researchPack", researchPack); + // Get all analysis types and detail level List 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 market = reportData != null && reportData.get("market") instanceof Map + ? (Map) 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 благодаря модульной генерации diff --git a/src/main/java/kz/konturai/parser/service/SerperSearchService.java b/src/main/java/kz/konturai/parser/service/SerperSearchService.java new file mode 100644 index 0000000..71bf19c --- /dev/null +++ b/src/main/java/kz/konturai/parser/service/SerperSearchService.java @@ -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 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 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 response = (Map) call.block(); + List 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 extractOrganicItems(Map response) { + if (response == null || response.isEmpty()) { + return List.of(); + } + Object organicObj = response.get("organic"); + if (!(organicObj instanceof List organicList)) { + return List.of(); + } + + List 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; + } + } +} + + diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 9e79da5..737655d 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -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