diff --git a/src/main/java/kz/konturai/parser/service/MarketingAnalysisService.java b/src/main/java/kz/konturai/parser/service/MarketingAnalysisService.java index 0d2afeb..62fcf0a 100644 --- a/src/main/java/kz/konturai/parser/service/MarketingAnalysisService.java +++ b/src/main/java/kz/konturai/parser/service/MarketingAnalysisService.java @@ -444,11 +444,6 @@ public class MarketingAnalysisService { || "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()); @@ -643,14 +638,16 @@ public class MarketingAnalysisService { logger.info("Starting Marketing Analysis V2 (Big 4 Standard) for request: {}", request); try { + // 1. Сбор данных Map researchPack = buildResearchPack(request); List competitorCompanies = resolveCompetitorCompaniesForBenchmarks(request, researchPack, 5); researchPack.put("competitor_companies", competitorCompanies); + // 2. Пример JSON (Обновлен под КЗ) MarketingAnalysisResponseV2 exampleDto = buildAnalysisV2ExampleForPrompt(request, competitorCompanies); - exampleDto.setSources(List.of("https://stat.gov.kz/example", "https://forbes.kz/example")); String structureExampleJson = objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(exampleDto); + // 3. Формирование промпта (Безопасный метод!) String systemPrompt = buildSystemPromptForKazakhstan(); String instruction = buildDetailedInstruction(request, competitorCompanies, structureExampleJson); @@ -661,23 +658,32 @@ public class MarketingAnalysisService { for (int attempt = 1; attempt <= 2; attempt++) { try { + // Вызов AI String aiResponse = openAIAnalyticsService.generateWithInstructionWithModel( contextJson, instruction, "ru", - textModelName, + textModelName, // GPT-4o systemPrompt, 16000, 180000L ); + if (aiResponse == null || aiResponse.isBlank()) { + throw new IllegalStateException("OpenAI returned empty response"); + } + String cleaned = cleanJsonFromMarkdown(aiResponse); + if (cleaned != null) cleaned = cleaned.replace("\uFEFF", "").trim(); + + MarketingAnalysisResponseV2 parsed = objectMapper.readValue(cleaned, MarketingAnalysisResponseV2.class); replacePlaceholderCompanyKeysIfNeeded(parsed, competitorCompanies); return parsed; } catch (Exception e) { logger.error("JSON Parsing/Generation failed on attempt {}: {}", attempt, e.getMessage()); if (attempt == 2) throw e; + Thread.sleep(2000); } } throw new IllegalStateException("Failed to generate valid analysis after retries."); @@ -699,54 +705,68 @@ public class MarketingAnalysisService { private String buildDetailedInstruction(MarketingAnalysisRequest request, List competitorCompanies, String structureExampleJson) { - List 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 regionsList = (request.getRegion() != null && !request.getRegion().isEmpty()) + ? String.join(", ", request.getRegion()) + : "Алматы, Астана, Шымкент"; String ageRangesInstruction = ""; + if (request.getTargetAudience() != null && request.getTargetAudience().containsKey("ageRanges")) { + @SuppressWarnings("unchecked") + List ranges = (List) request.getTargetAudience().get("ageRanges"); + if (ranges != null && !ranges.isEmpty()) { + ageRangesInstruction = "Используй ТОЛЬКО эти возрастные группы: " + String.join(", ", ranges); + } } - String channelsInstruction = "КАНАЛЫ (РК SPECS):\n" + - "- В 'acquisition_channels' обязательно включи: Instagram, TikTok, Kaspi Marketing, 2GIS, Google Ads.\n" + - "- ЗАПРЕЩЕНО включать или 'ВКонтакте' как платные каналы (они не работают в РК). Если они есть в примере - удали их.\n" + - "- Оценивай эффективность реально: 2GIS работает локально, Kaspi дает горячих лидов."; + String template = """ + Твоя задача: Провести глубокий маркетинговый анализ и вернуть его СТРОГО в формате JSON. + + ВХОДНЫЕ ДАННЫЕ: + Ниша: {{NICHE}} + Продукт: {{PRODUCT}} + + ----------------------------------------------------------------------- + КРИТИЧЕСКИЕ ИНСТРУКЦИИ ПО КАЗАХСТАНУ (KZ): + + 1. ГЕОГРАФИЯ (market_overview.geography): + - Ты ОБЯЗАН включить в анализ ВСЕ следующие города: [{{REGIONS}}]. + - Не ограничивайся тремя городами, если в списке их больше. + - Значения (0-100) должны быть разными и неровными (например, 87, 64, 43). + + 2. КОНКУРЕНТЫ (competitive_environment): + - Найди в `evidence` РЕАЛЬНЫХ лидеров (сети, крупные игроки из 2GIS/Kaspi). + - Игнорируй мелкие ИП или выдуманные названия. + - Заполни `digital_visibility_benchmarks` реальными именами. + + 3. КАНАЛЫ (acquisition_channels): + - Приоритет в РК: Instagram (Reels), TikTok, Kaspi Marketing, 2GIS. + - Яндекс.Директ и VK ставь ниже (Low/Medium), если это B2C. + - Указывай стоимость лида (CPL) неровными числами (напр. 2350 KZT). + + 4. ЦИФРЫ И ГРАФИКИ: + - ЗАПРЕЩЕНО использовать круглые числа (20%, 50%, 1000). Пиши: 18.7%, 42.3%, 1240. + - income_by_age: Заполни реальными доходами в KZT по возрастам (из evidence). + - monthly_online_index: График должен быть "живым" (не прямая линия). + + 5. ИСТОЧНИКИ: + - Собери ВСЕ ссылки из `evidence` в ОДИН СПИСОК в самом конце (поле `recommendations.conclusion`). + - Не вставляй ссылки внутрь текста разделов. + + ----------------------------------------------------------------------- + {{AGE_INSTRUCTION}} + + ФОРМАТ ОТВЕТА: + - Только валидный JSON. + - Заполни все поля `EXAMPLE__` и `-9999` реальными данными. + + ЭТАЛОННАЯ СТРУКТУРА: + {{JSON_STRUCTURE}} + """; - String audienceInstruction = "АУДИТОРИЯ:\n" + - "- Следи, чтобы возрастные группы в 'age_structure' НЕ пересекались по логике, но если входные данные пересекаются - нормализуй их так, чтобы сумма % была равна 100%.\n" + - "- Поле 'income_by_age' ОБЯЗАТЕЛЬНО для заполнения (средний доход в KZT)."; - - return String.format(""" - Твоя задача: Провести глубокий маркетинговый анализ для рынка Казахстана и вернуть JSON. - Ниша: %s - Продукт: %s - - %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(), - regionsInstruction, - channelsInstruction, - audienceInstruction, - structureExampleJson - ); + return template + .replace("{{NICHE}}", request.getBusinessNiche()) + .replace("{{PRODUCT}}", request.getProduct()) + .replace("{{REGIONS}}", regionsList) + .replace("{{AGE_INSTRUCTION}}", ageRangesInstruction) + .replace("{{JSON_STRUCTURE}}", structureExampleJson); } private String buildJsonStructureExample() { return """