This commit is contained in:
arys
2026-01-17 17:19:23 +05:00
parent 12c08dfc03
commit ace178274d
5 changed files with 227 additions and 79 deletions
+6
View File
@@ -130,6 +130,12 @@
<version>1.17</version>
</dependency>
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>2.5.0</version>
</dependency>
<!-- Apache Batik for SVG -> PNG conversion -->
<dependency>
<groupId>org.apache.xmlgraphics</groupId>
@@ -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")
));
}
}
@@ -249,11 +249,26 @@ public class MarketingController {
Optional<MarketingAnalysis> optAnalysis = marketingAnalysisService.getAnalysisById(analysisId);
if (optAnalysis.isEmpty()) {
Optional<MarketingAnalysisV2Document> 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();
@@ -11,6 +11,7 @@ public class MarketingAnalysisResponseV2 {
private String reportTitle;
private Sections sections;
private List<String> sources;
public MarketingAnalysisResponseV2() {
}
@@ -36,6 +37,9 @@ public class MarketingAnalysisResponseV2 {
this.sections = sections;
}
public void setSources(List<String> sources) { this.sources = sources; }
public List<String> getSources() { return sources; }
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
public static class Sections {
private MarketOverview marketOverview;
@@ -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<String, Object> researchPack = buildResearchPack(request);
// 2) Build strict schema instructions + provide an example JSON structure
// 2) Resolve Competitors with strict filtering
// Фильтруем мусор, оставляем только реальные бренды для контекста
List<String> 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<String, Object> targetAudienceData = request.getTargetAudience();
if (targetAudienceData != null && targetAudienceData.containsKey("ageRanges")) {
@SuppressWarnings("unchecked")
List<String> ageRanges = (List<String>) 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<String> competitorCompanies) {
// Формируем строгую инструкцию по возрастным группам, если они заданы
String ageRangesInstruction = "";
Map<String, Object> targetAudienceData = request.getTargetAudience();
if (targetAudienceData != null && targetAudienceData.containsKey("ageRanges")) {
@SuppressWarnings("unchecked")
List<String> ageRanges = (List<String>) 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;