diff --git a/pom.xml b/pom.xml index 7ba95c6..a2b98a2 100644 --- a/pom.xml +++ b/pom.xml @@ -130,6 +130,12 @@ 1.17 + + org.springdoc + springdoc-openapi-starter-webmvc-ui + 2.5.0 + + org.apache.xmlgraphics diff --git a/src/main/java/kz/konturai/parser/config/SwaggerConfig.java b/src/main/java/kz/konturai/parser/config/SwaggerConfig.java new file mode 100644 index 0000000..9470566 --- /dev/null +++ b/src/main/java/kz/konturai/parser/config/SwaggerConfig.java @@ -0,0 +1,39 @@ +package kz.konturai.parser.config; + +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; +import io.swagger.v3.oas.models.servers.Server; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.util.List; + +@Configuration +public class SwaggerConfig { + + @Bean + public OpenAPI api() { + final String securitySchemeName = "bearerAuth"; + + return new OpenAPI() + .servers(List.of(new Server().url("http://localhost:8080"))) + .info(new Info() + .title("Название вашего API") + .description("Здесь описание того, что делает ваше приложение") + .version("1.0.0")) + // 1. Добавляем возможность ввода токена (кнопка Authorize) + .addSecurityItem(new SecurityRequirement().addList(securitySchemeName)) + // 2. Описываем схему безопасности (тип, формат токена) + .components(new Components() + .addSecuritySchemes(securitySchemeName, + new SecurityScheme() + .name(securitySchemeName) + .type(SecurityScheme.Type.HTTP) + .scheme("bearer") + .bearerFormat("JWT") + )); + } +} \ No newline at end of file diff --git a/src/main/java/kz/konturai/parser/controller/MarketingController.java b/src/main/java/kz/konturai/parser/controller/MarketingController.java index dec9b2d..223bd3d 100644 --- a/src/main/java/kz/konturai/parser/controller/MarketingController.java +++ b/src/main/java/kz/konturai/parser/controller/MarketingController.java @@ -249,11 +249,26 @@ public class MarketingController { Optional optAnalysis = marketingAnalysisService.getAnalysisById(analysisId); if (optAnalysis.isEmpty()) { + Optional newOptAnalyz = marketingAnalysisService + .getAnalysisV2ById(analysisId); + if(newOptAnalyz.isEmpty()) { ErrorResponse error = new ErrorResponse( - "NOT_FOUND", - "Анализ с указанным ID не найден"); + "NOT_FOUND", + "Анализ с указанным ID не найден"); return ResponseEntity.status(404) - .body(ApiResponse.error("Анализ не найден", error)); + .body(ApiResponse.error("Анализ не найден", error)); + } + MarketingAnalysisV2Document analysis = newOptAnalyz.get(); + + if (!userId.equals(analysis.getUserId())) { + ErrorResponse error = new ErrorResponse( + "FORBIDDEN", + "У вас нет доступа к этому анализу"); + return ResponseEntity.status(HttpStatus.FORBIDDEN) + .body(ApiResponse.error("Доступ запрещен", error)); + } + + return ResponseEntity.ok(ApiResponse.success(analysis.getAnalysisData())); } MarketingAnalysis analysis = optAnalysis.get(); diff --git a/src/main/java/kz/konturai/parser/dto/MarketingAnalysisResponseV2.java b/src/main/java/kz/konturai/parser/dto/MarketingAnalysisResponseV2.java index 3500b77..3b1c734 100644 --- a/src/main/java/kz/konturai/parser/dto/MarketingAnalysisResponseV2.java +++ b/src/main/java/kz/konturai/parser/dto/MarketingAnalysisResponseV2.java @@ -11,6 +11,7 @@ public class MarketingAnalysisResponseV2 { private String reportTitle; private Sections sections; + private List sources; public MarketingAnalysisResponseV2() { } @@ -36,6 +37,9 @@ public class MarketingAnalysisResponseV2 { this.sections = sections; } + public void setSources(List sources) { this.sources = sources; } + public List getSources() { return sources; } + @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) public static class Sections { private MarketOverview marketOverview; diff --git a/src/main/java/kz/konturai/parser/service/MarketingAnalysisService.java b/src/main/java/kz/konturai/parser/service/MarketingAnalysisService.java index a2b8f66..f6acad8 100644 --- a/src/main/java/kz/konturai/parser/service/MarketingAnalysisService.java +++ b/src/main/java/kz/konturai/parser/service/MarketingAnalysisService.java @@ -654,122 +654,206 @@ public class MarketingAnalysisService { * Generates a strict JSON response via OpenAI, grounded on Serper web evidence. */ public MarketingAnalysisResponseV2 generateAnalysisV2(MarketingAnalysisRequest request) { + logger.info("Starting Marketing Analysis V2 (Big 4 Standard) for request: {}", request); + try { - // 1) Gather web evidence + // 1) Gather web evidence (Deep Search Strategy) + // Мы расширяем набор данных, чтобы найти реальные казахстанские инсайты Map researchPack = buildResearchPack(request); - // 2) Build strict schema instructions + provide an example JSON structure + // 2) Resolve Competitors with strict filtering + // Фильтруем мусор, оставляем только реальные бренды для контекста List competitorCompanies = resolveCompetitorCompaniesForBenchmarks(request, researchPack, 5); researchPack.put("competitor_companies", competitorCompanies); - String exampleJson = objectMapper.writeValueAsString( - buildAnalysisV2ExampleForPrompt(request, competitorCompanies)); - String systemPrompt = "" - + "Ты — Партнер международной консалтинговой фирмы (Big 4) и ведущий маркетинговый стратег с 50-летним опытом.\n" - + "Твоя специализация: Рынок Казахстана. Ты знаешь всё про тенге, Kaspi, менталитет и реальную инфляцию.\n" - + "СТИЛЬ: Уверенный, профессиональный, структурный. Никакой воды. Только факты и обоснованные выводы.\n" - + "КРИТИЧЕСКИ ВАЖНО: Твой ответ должен быть ТОЛЬКО валидный JSON, без markdown, без ```.\n" - + "ЗАПРЕТ: Не выдумывай бренды (используй только найденные) и не генерируй случайные 'круглые' числа для графиков.\n"; + // 3) Construct the "Big 4 Partner" System Prompt + // Здесь вся магия: задаем контекст, валюту, каналы и запрещаем галлюцинации. + String systemPrompt = buildSystemPromptForKazakhstan(); - // Извлекаем возрастные группы из запроса для инструкции (старая логика сохранена) - String ageRangesInstruction = ""; - Map targetAudienceData = request.getTargetAudience(); - if (targetAudienceData != null && targetAudienceData.containsKey("ageRanges")) { - @SuppressWarnings("unchecked") - List ageRanges = (List) targetAudienceData.get("ageRanges"); - if (ageRanges != null && !ageRanges.isEmpty()) { - ageRangesInstruction = "\n" - + "8) КРИТИЧЕСКИ ВАЖНО для target_audience.age_structure: Используй ТОЛЬКО те возрастные группы, которые указаны в запросе (request.targetAudience.ageRanges). " - + "Указанные возрастные группы: " + String.join(", ", ageRanges) + ". " - + "НЕ добавляй возрастные группы, которых нет в запросе.\n"; - } - } - - String instruction = "" - + "Сгенерируй один JSON-объект, который СТРОГО соответствует схеме MarketingAnalysisResponseV2.\n" - + "Обязательные правила:\n" - + "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) Соблюдай типы данных (числа - number/integer, проценты - string).\n" - + "5) Пиши на русском языке.\n" - + "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; + // 4) Build specific user instructions & constraints + String instruction = buildDetailedInstruction(request, competitorCompanies); + // 5) Prepare Context JSON String contextJson = objectMapper.writeValueAsString(Map.of( "request", request, - "evidence", researchPack)); + "evidence", researchPack + )); + + // 6) Call LLM (with retry logic and placeholder cleaning) + // Используем gpt-4o для максимального качества аналитики + String lastCleanedResponse = null; - // 3) Call OpenAI (sleep to reduce 429 rate-limit bursts) + guard against - // copying placeholders - // ВАЖНО: Для генерации V2 отчёта ВСЕГДА используется gpt-4o (textModelName) - String lastCleaned = null; for (int attempt = 1; attempt <= 2; attempt++) { - try { - Thread.sleep(2000); - } catch (InterruptedException ie) { - Thread.currentThread().interrupt(); - } - - String attemptInstruction = instruction; if (attempt > 1) { - attemptInstruction = instruction - + "\n\nКРИТИЧЕСКИ: В ПРЕДЫДУЩЕМ ОТВЕТЕ БЫЛИ ПЛЕЙСХОЛДЕРЫ." - + " Перегенерируй ответ, полностью заменив любые строки, содержащие 'EXAMPLE__'," - + " и любые числа -9999 / -9999.0 на реальные значения." - + " Верни ТОЛЬКО JSON."; + // Если первый раз модель схалтурила, даем пинка + instruction += "\n\n!!! CRITICAL CORRECTION NEEDED !!!\n" + + "В предыдущем ответе были замечены плейсхолдеры (EXAMPLE__) или невалидные данные.\n" + + "Ты обязан использовать ТОЛЬКО реальные данные из evidence. Перегенерируй JSON."; + try { Thread.sleep(2000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } String aiResponse = openAIAnalyticsService.generateWithInstructionWithModel( contextJson, - attemptInstruction, + instruction, "ru", - textModelName, + textModelName, // gpt-4o strictly systemPrompt, 16000, - 180000L); + 180000L + ); if (aiResponse == null || aiResponse.isBlank()) { throw new IllegalStateException("OpenAI returned empty response for analysis v2"); } - // 4) Clean + validate placeholders + parse String cleaned = cleanJsonFromMarkdown(aiResponse); if (cleaned != null) { cleaned = cleaned.replace("\uFEFF", "").trim(); } - lastCleaned = cleaned; + lastCleanedResponse = cleaned; + // Validate against placeholders if (containsV2ExamplePlaceholders(cleaned)) { - logger.warn("generateAnalysisV2: LLM output still contains placeholders (attempt {}/2). Retrying.", - attempt); + logger.warn("generateAnalysisV2: Response contained placeholders (Attempt {}). Retrying...", attempt); continue; } - MarketingAnalysisResponseV2 parsed = objectMapper.readValue(cleaned, MarketingAnalysisResponseV2.class); - replacePlaceholderCompanyKeysIfNeeded(parsed, competitorCompanies); - return parsed; + // 7) Parse and Return + try { + MarketingAnalysisResponseV2 parsed = objectMapper.readValue(cleaned, MarketingAnalysisResponseV2.class); + + // Финальная полировка данных, если вдруг модель оставила ключи-заглушки + replacePlaceholderCompanyKeysIfNeeded(parsed, competitorCompanies); + + return parsed; + } catch (Exception e) { + logger.error("JSON Parsing failed on attempt {}: {}", attempt, e.getMessage()); + // Если это была последняя попытка - падаем, иначе пробуем снова + if (attempt == 2) throw e; + } } - throw new IllegalStateException( - "OpenAI returned response containing placeholder tokens for analysis v2: " + lastCleaned); + throw new IllegalStateException("Failed to generate valid analysis after retries."); + } catch (Exception e) { - logger.error("generateAnalysisV2: Failed to generate/parse v2 response: {}", e.getMessage(), e); - throw new RuntimeException("Не удалось сгенерировать marketing analysis v2", e); + logger.error("generateAnalysisV2: Fatal error: {}", e.getMessage(), e); + throw new RuntimeException("Не удалось сгенерировать маркетинговый анализ V2: " + e.getMessage(), e); } } + /** + * Строит системный промпт, задающий ролевую модель и контекст Казахстана. + */ + private String buildSystemPromptForKazakhstan() { + return """ + Ты — Партнер международной консалтинговой фирмы (Big 4: McKinsey, BCG, PwC) с офисом в Алматы. + Твой опыт: 50 лет в маркетинговой стратегии и аналитике рынков Центральной Азии. + + ТВОЯ СПЕЦИАЛИЗАЦИЯ — КАЗАХСТАН (KZ): + 1. **Экономика**: Ты мыслишь в Тенге (KZT). Ты понимаешь реальную покупательскую способность, закредитованность населения и инфляцию. + 2. **Платформы**: Ты знаешь, что в РК **Kaspi.kz** — это экосистема №1 (Shop, Travel, Pay). + Ты знаешь, что **Instagram** и **TikTok** продают лучше, чем сайты. + Ты знаешь, что **2GIS** важнее для локального бизнеса, чем Google Maps. + Ты практически игнорируешь "ВКонтакте", "Яндекс.Маркет" или "Ozon", если только это не специфичный трансграничный кейс. + 3. **Менталитет**: Ты учитываешь важность рекомендаций ("сарафан"), семейных связей, статусности потребления и любовь к рассрочкам (Kaspi Red/Kredit). + + ТВОЙ СТИЛЬ: + - Жесткий, фактологический, структурный. + - Никакой "воды" и общих фраз ("динамично развивающийся рынок"). Только инсайты. + - Ты презираешь выдуманные данные. Если точной цифры нет в evidence — ты делаешь обоснованную аналитическую эстимацию (Analytical Extrapolation), базируясь на смежных метриках. + + ФОРМАТ ОТВЕТА: + - ТОЛЬКО валидный JSON. + - Никакого Markdown форматирования (```json ... ```) вокруг ответа. + """; + } + + /** + * Строит детальную инструкцию по заполнению полей JSON. + */ + private String buildDetailedInstruction(MarketingAnalysisRequest request, List competitorCompanies) { + // Формируем строгую инструкцию по возрастным группам, если они заданы + String ageRangesInstruction = ""; + Map targetAudienceData = request.getTargetAudience(); + if (targetAudienceData != null && targetAudienceData.containsKey("ageRanges")) { + @SuppressWarnings("unchecked") + List ageRanges = (List) targetAudienceData.get("ageRanges"); + if (ageRanges != null && !ageRanges.isEmpty()) { + ageRangesInstruction = " - В поле `target_audience.age_structure` и `income_by_age` используй СТРОГО эти группы: " + + String.join(", ", ageRanges) + ".\n"; + } + } + + // Пример JSON (структура), чтобы модель не ошиблась в типах данных + // Важно: мы не передаем values, только keys + String structureExample = buildJsonStructureExample(); + + return String.format(""" + Сгенерируй JSON объект `MarketingAnalysisResponseV2` для запроса: + Ниша: %s + Продукт: %s + Регион: %s + + ИНСТРУКЦИИ ПО ЗАПОЛНЕНИЮ СЕКЦИЙ: + + 1. **ИСТОЧНИКИ (sources)**: + - В поле `sources` (массив строк) в корне JSON собери ВСЕ URL-ссылки, которые ты использовал для аналитики из предоставленного `evidence`. + - В текстах (conclusion, descriptions) ССЫЛКИ НЕ СТАВИТЬ. Текст должен быть чистым. + + 2. **КАНАЛЫ (acquisition_channels)**: + - Приоритеты для Казахстана: + 1) **Instagram / TikTok** (Таргет, блогеры). + 2) **Kaspi Marketing** (Для товарки - обязательно). + 3) **Google Ads / SEO** (Поиск). + 4) **2GIS** (Для офлайн точек). + - Забудь про Яндекс.Директ и ВКонтакте, если это не узкая B2B специфика. + + 3. **КОНКУРЕНТЫ**: + - Используй только эти названия (если они релевантны): %s. + - Если список пуст, найди лучших в `evidence.followups.competitors`. + - Не используй выдуманные названия типа "Магазин у дома". + + 4. **ДЕНЬГИ (marketing_economics)**: + - Все суммы в KZT (Тенге). + - Учитывай реальную стоимость клика (CPC) и лида (CPL) в Казахстане. Не занижай цены. + + 5. **ГРАФИКИ**: + - `monthly_online_index`: Учитывай сезонность (Наурыз, Back to School, Новый год, Каспи Жума). + - `income_by_age`: Доход в Тенге. Реальные зарплаты по данным Бюро Нацстатистики (evidence). + + %s + + ВАЖНО: Верни ТОЛЬКО ЧИСТЫЙ JSON. Без `Markdown`. Без комментариев. + + Пример структуры (заполни его реальными данными из evidence): + %s + """, + request.getBusinessNiche(), + request.getProduct(), + request.getRegion() != null ? request.getRegion() : "Казахстан", + String.join(", ", competitorCompanies), + ageRangesInstruction, + structureExample + ); + } + + private String buildJsonStructureExample() { + return """ + { + "report_title": "Стратегический анализ рынка...", + "sources": ["https://stat.gov.kz/...", "https://forbes.kz/..."], + "sections": { + "market_overview": { "metrics": { "online_demand_index": "0.8" }, "conclusion": "Текст..." }, + "acquisition_channels": { + "channel_shares": { "Instagram": "40%", "Kaspi Shop": "30%", "Google SEO": "20%" }, + "conclusion": "Текст..." + }, + "recommendations": { "core_pillars": [ { "strategy": "...", "action": "..." } ] } + } + } + """; + } + private boolean containsV2ExamplePlaceholders(String json) { if (json == null || json.isBlank()) { return true;