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;