@@ -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 (a ttempt {}/2 ). Retrying. " ,
attempt ) ;
logger . warn ( " generateAnalysisV2: Response contained placeholders (A ttempt {}). 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 ;