fix
This commit is contained in:
@@ -22,7 +22,6 @@ import org.springframework.stereotype.Service;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonParseException;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
@@ -34,18 +33,6 @@ import org.springframework.web.reactive.function.client.WebClientResponseExcepti
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class MarketingAnalysisService {
|
||||
|
||||
private static final String KZ_MARKET_CONTEXT =
|
||||
"РОЛЬ: Ты — Партнер международной консалтинговой фирмы (Big 4: McKinsey/BCG/PwC) с офисом в Алматы.\n" +
|
||||
"СПЕЦИАЛИЗАЦИЯ: Глубокий анализ рынка Казахстана. Ты мыслишь в Тенге (KZT).\n" +
|
||||
"ТВОИ ЗНАНИЯ О РЫНКЕ KZ (СТРОГИЕ ПРАВИЛА):\n" +
|
||||
"1. SuperApps: Kaspi.kz — это экосистема №1 (Магазин, Travel, Gov, Платежи). Это главный канал продаж для товарки и услуг.\n" +
|
||||
"2. Карты: 2GIS в Казахстане популярнее Google Maps для поиска услуг офлайн. Отзывы в 2GIS критически важны.\n" +
|
||||
"3. Фитнес-рынок: Лидеры — 1Fit (единый абонемент, НЕ переводи это название), Invictus, World Class, FitnessBlitz.\n" +
|
||||
"4. Соцсети: Instagram (Direct/Reels) и TikTok — главные каналы продаж. ВКонтакте и Threads — мертвы для коммерции в РК, ЗАПРЕЩЕНО их рекомендовать.\n" +
|
||||
"5. Экономика: Реальная инфляция ~12-15%. Потребитель чувствителен к цене, любит рассрочки (Kaspi Red/Kredit).\n" +
|
||||
"ВАЛЮТА: Только KZT. Округляй цифры до сотен (например, 15 000 KZT, а не 14 321).\n";
|
||||
|
||||
private static final String AI_MANDATORY_JSON_USAGE_WARNING = "ВНИМАНИЕ: Если в предоставленном JSON есть данные, ты ОБЯЗАН использовать их и вставить соответствующий тег [[CHART_...]]. "
|
||||
+ "Никогда не пиши, что данных нет, если JSON не пустой.\n\n";
|
||||
private static final String AI_ULTIMATE_JSON_PRESENCE_INSTRUCTION = ""
|
||||
@@ -59,10 +46,7 @@ public class MarketingAnalysisService {
|
||||
+ "ФОРМАТ (ОБЯЗАТЕЛЬНО): пиши аналитический текст короткими емкими абзацами (2–4 предложения), без «воды».\n"
|
||||
+ "ГРАФИКИ (ОБЯЗАТЕЛЬНО): располагай теги графиков (например, [[CHART_...]]) непосредственно внутри или сразу после описания конкретных данных/цифр, к которым относится график.\n\n";
|
||||
|
||||
private static final int REPORT_BASE_MAX_TOKENS = 12000;
|
||||
private static final int REPORT_DETAILED_MAX_TOKENS = 12000;
|
||||
private static final int JSON_DETAILED_MAX_TOKENS = 12000;
|
||||
|
||||
private DetailLevel resolveDetailLevelFromContext(Map<String, Object> contextJson) {
|
||||
if (contextJson == null) {
|
||||
return null;
|
||||
@@ -133,37 +117,6 @@ public class MarketingAnalysisService {
|
||||
}
|
||||
}
|
||||
|
||||
private String selectPrimaryModelForTextSection(DetailLevel detailLevel, String standardPrimaryModel) {
|
||||
if (detailLevel == null) {
|
||||
return standardPrimaryModel;
|
||||
}
|
||||
if (detailLevel == DetailLevel.КРАТКО) {
|
||||
return miniModelName;
|
||||
}
|
||||
if (detailLevel == DetailLevel.ПОДРОБНО) {
|
||||
return textModelName;
|
||||
}
|
||||
return standardPrimaryModel;
|
||||
}
|
||||
|
||||
private String selectFallbackModelForTextSection(DetailLevel detailLevel, String standardFallbackModel) {
|
||||
if (detailLevel == null) {
|
||||
return standardFallbackModel;
|
||||
}
|
||||
if (detailLevel == DetailLevel.КРАТКО || detailLevel == DetailLevel.ПОДРОБНО) {
|
||||
return null;
|
||||
}
|
||||
return standardFallbackModel;
|
||||
}
|
||||
|
||||
private String selectModelForJsonAnalysis(DetailLevel detailLevel) {
|
||||
if (detailLevel == DetailLevel.ПОДРОБНО) {
|
||||
return textModelName;
|
||||
}
|
||||
// КРАТКО и СТАНДАРТНО: всегда mini
|
||||
return miniModelName;
|
||||
}
|
||||
|
||||
private Map<String, Object> buildResearchNotesForSection(Map<String, Object> contextJson, String sectionId) {
|
||||
if (contextJson == null) {
|
||||
return new HashMap<>();
|
||||
@@ -297,15 +250,11 @@ public class MarketingAnalysisService {
|
||||
|
||||
String result = text;
|
||||
|
||||
// 1) Standalone line occurrences (allow indentation). Replace the whole line
|
||||
// to keep fences at the start of a line (no leading spaces before ```).
|
||||
java.util.regex.Pattern standaloneLine = java.util.regex.Pattern.compile(
|
||||
"(?m)^[ \\t]*" + java.util.regex.Pattern.quote(placeholderVariant) + "[ \\t]*$");
|
||||
result = standaloneLine.matcher(result)
|
||||
.replaceAll(java.util.regex.Matcher.quoteReplacement(jsonBlock));
|
||||
|
||||
// 2) Inline occurrences: force the block onto its own line with minimal
|
||||
// spacing.
|
||||
java.util.regex.Pattern inline = java.util.regex.Pattern
|
||||
.compile(java.util.regex.Pattern.quote(placeholderVariant));
|
||||
java.util.regex.Matcher m = inline.matcher(result);
|
||||
@@ -477,7 +426,6 @@ public class MarketingAnalysisService {
|
||||
try {
|
||||
general.add(serperSearchService.search(query2));
|
||||
} catch (Exception e) {
|
||||
// Keeping existing error handling
|
||||
String errorType = "Unexpected error";
|
||||
if (e instanceof java.net.UnknownHostException || e instanceof java.net.ConnectException)
|
||||
errorType = "DNS/Network failure";
|
||||
@@ -487,13 +435,11 @@ public class MarketingAnalysisService {
|
||||
general.add(new SerperSearchResult(query2, "ERROR", errorType + ": " + e.getMessage(), List.of()));
|
||||
}
|
||||
|
||||
// Lightweight section followups (stored for auditing/citations). Prompts for III/V/XI may further use these.
|
||||
Map<String, Object> followups = new LinkedHashMap<>();
|
||||
String marketQuery = topic + " объем рынка Казахстан 2024 2025 отчет " + preferredSites;
|
||||
try {
|
||||
followups.put("market", serperSearchService.search(marketQuery));
|
||||
} catch (Exception e) {
|
||||
// Keeping existing error handling
|
||||
String errorType = "Unexpected error";
|
||||
if (e instanceof java.net.UnknownHostException || e instanceof java.net.ConnectException)
|
||||
errorType = "DNS/Network failure";
|
||||
@@ -612,7 +558,6 @@ public class MarketingAnalysisService {
|
||||
throw new IllegalArgumentException("Необходимо выбрать хотя бы один тип анализа");
|
||||
}
|
||||
|
||||
// Create a single unified analysis with all analysis types
|
||||
MarketingAnalysis analysis = new MarketingAnalysis(
|
||||
request.getBusinessNiche(),
|
||||
request.getProduct(),
|
||||
@@ -622,7 +567,7 @@ public class MarketingAnalysisService {
|
||||
request.getDetailLevel(),
|
||||
request.getStrongSide(),
|
||||
request.getWeakSide(),
|
||||
analysisTypes); // Все типы анализа в одном анализе
|
||||
analysisTypes);
|
||||
analysis.setUserId(userId);
|
||||
analysis.setStatus("queued");
|
||||
addStatusHistoryEntry(analysis, "queued", "Комплексный анализ создан и добавлен в очередь");
|
||||
@@ -640,7 +585,7 @@ public class MarketingAnalysisService {
|
||||
try {
|
||||
// 1. Сбор данных
|
||||
Map<String, Object> researchPack = buildResearchPack(request);
|
||||
List<String> competitors = resolveCompetitorCompaniesForBenchmarks(request, researchPack, 5);
|
||||
List<String> competitors = resolveCompetitorCompaniesForBenchmarks(request, researchPack);
|
||||
|
||||
// 2. Генерируем ШАБЛОН СТРУКТУРЫ (пустой, с плейсхолдерами)
|
||||
MarketingAnalysisResponseV2 structureTemplate = buildAnalysisV2ExampleForPrompt(request, competitors);
|
||||
@@ -654,7 +599,6 @@ public class MarketingAnalysisService {
|
||||
"competitors_list", competitors
|
||||
));
|
||||
|
||||
// 4. Вызов AI (с защитой от сбоев)
|
||||
for (int attempt = 1; attempt <= 2; attempt++) {
|
||||
try {
|
||||
String aiResponse = openAIAnalyticsService.generateWithInstructionWithModel(
|
||||
@@ -671,10 +615,8 @@ public class MarketingAnalysisService {
|
||||
throw new IllegalStateException("OpenAI returned empty response");
|
||||
}
|
||||
|
||||
// Чистка
|
||||
String cleaned = cleanJsonFromMarkdown(aiResponse);
|
||||
|
||||
// ЭКСТРЕННАЯ ЗАЩИТА: Если он все-таки сгенерил "value", вырезаем его на лету
|
||||
if (cleaned.contains("\"value\"")) {
|
||||
logger.warn("AI hallucinated 'value' field. Attempting to fix string.");
|
||||
cleaned = cleaned.replaceAll("\"value\"\\s*:", "\"ignore_this_field\":");
|
||||
@@ -726,15 +668,14 @@ public class MarketingAnalysisService {
|
||||
|
||||
4. **Экономика:**
|
||||
* Реальная инфляция потребительской корзины: ~10-15%.
|
||||
* Средняя ЗП: официально ~350-400к, медианная ~250к. Учитывай это при оценке емкости рынка!
|
||||
* Язык: Двуязычие. Реклама на казахском языке растет в эффективности (особенно на Юге и Западе).
|
||||
|
||||
=============== 2. ПРОТОКОЛ ЗАЩИТЫ ОТ ШАБЛОНОВ (ANTI-HALLUCINATION) ===============
|
||||
СТРОГО ЗАПРЕЩЕНО:
|
||||
1. Использовать кейс "Фитнес" (1Fit, Sport Zal), если пользователь спрашивает про другую нишу.
|
||||
2. Писать "+18.5% YoY", если у тебя нет данных. Генерируй реалистичный тренд для КОНКРЕТНОЙ ниши (для хлеба это +2%, для IT +25%).
|
||||
2. Писать "+18.5% YoY", если у тебя нет данных. Генерируй реалистичный тренд для КОНКРЕТНОЙ ниши
|
||||
3. Выдумывать несуществующие законы РК.
|
||||
4. Оставлять поля `null`. Если данных нет — используй метод Ферми (логическую оценку) и пиши "Оценочно ~X".
|
||||
4. Оставлять поля `null`. Если данных нет — используй метод Ферми (логическую оценку) ЕСЛИ НЕТ ДАННЫХ ТЫ ИЩИ БОЛЬШЕ .
|
||||
|
||||
=============== 3. АНАЛИТИЧЕСКИЙ ФРЕЙМВОРК (КАК ДУМАТЬ) ===============
|
||||
Перед генерацией JSON, прогони запрос через этот алгоритм:
|
||||
@@ -756,7 +697,9 @@ public class MarketingAnalysisService {
|
||||
3. Поле `income_by_age` должно содержать ЧИСЛА (Integer), а не строки.
|
||||
4. Структура JSON должна строго соответствовать запрошенному примеру (но с ТВОИМИ данными).
|
||||
|
||||
Твоя репутация зависит от точности цифр и глубины инсайтов. Действуй.
|
||||
Твоя репутация зависит от точности цифр и глубины инсайтов. Действуй. ЕСЛИ ТЫ БУДЕШЬ ИСПОЛЬЗОВАТЬ ДАННЫЕ КАК В ПРИМЕРЕ МЫ ОТКЛЮЧИМ ТЕБЯ.
|
||||
ПОМНИ, ЧТО ПРИМЕР КОТОРЫЙ МЫ ПРИВЕЛИ JSON СТРУКТУРЫ ЭТО ПРОСТО ПРИМЕР, ДЛЯ КАЖДОГО ПОЛЯ ТЫ ДОЛЖЕН АБСОЛЮТНО ВЫДАВАТЬ ТОЛЬКО ПРАВДИВУЮ И ПОЛНОСТЬЮ ЧИСТЫЕ ДАННЫЕ, КОТОРЫЕ ДАСТ НАШИМ КЛИЕНТАМ
|
||||
ВОЗМОЖНОСТЬ И ПОНЯТИЯ НАСТОЯЩЕЙ СФЕРЫ КОТОРУЮ ОНИ УКАЗАЛИ
|
||||
""";
|
||||
}
|
||||
|
||||
@@ -799,10 +742,10 @@ public class MarketingAnalysisService {
|
||||
1. **ГЕОГРАФИЯ (Market Overview):**
|
||||
- Ты ОБЯЗАН включить в анализ (`geography`) ТОЛЬКО указанные города: [{{REGIONS}}].
|
||||
- Если указан "Весь Казахстан" или список пуст — бери Топ-5 (Алматы, Астана, Шымкент...).
|
||||
- Распределение: Алматы обычно лидер (40-50%), Астана (30%), регионы меньше.
|
||||
|
||||
|
||||
2. **КОНКУРЕНТЫ (Competitive Environment):**
|
||||
- Используй данные из `market_evidence_from_search`. Найди 3-5 РЕАЛЬНЫХ игроков.
|
||||
- Используй данные из `market_evidence_from_search`. Найди ТОП 5 РЕАЛЬНЫХ игроков.
|
||||
- **ВАЖНО:** В поле `name` пиши ТОЛЬКО БРЕНД (например, "Fitnation", а не "ТОО Fitnation Group"). Без кавычек и юр. форм.
|
||||
- В поле `website` попытайся найти реальный домен или ссылку на Instagram.
|
||||
- Оценка видимости (`visibility_score`) должна быть разной (например: 95, 82, 60, 45). Не ставь всем 100.
|
||||
@@ -819,7 +762,7 @@ public class MarketingAnalysisService {
|
||||
- `roi`: Реалистичный (150% - 300%). Не пиши фантастические 1000% на старте.
|
||||
|
||||
5. **ТИПЫ ДАННЫХ (Строго соблюдай типы):**
|
||||
- `income_by_age`: Значения - ТОЛЬКО ЧИСЛА (Integer). Пример: 350000. (Никаких кавычек, запятых, "KZT").
|
||||
- `income_by_age`: Значения - ТОЛЬКО ЧИСЛА (Integer). Пример: ТОЛЬКО ИНТЕДЖЕР. (Никаких кавычек, запятых, "KZT").
|
||||
- `monthly_online_index`: Дробные числа (0.0 - 1.0).
|
||||
|
||||
6. **ИСТОЧНИКИ:**
|
||||
@@ -830,10 +773,12 @@ public class MarketingAnalysisService {
|
||||
|
||||
ФОРМАТ ОТВЕТА:
|
||||
Верни СТРОГО валидный JSON, соответствующий структуре ниже.
|
||||
Замени все значения `EXAMPLE__` и `-9999` на реальные аналитические данные, специфичные для ниши {{NICHE}}.
|
||||
Замени все значения `EXAMPLE__` и `-9999` на реальные аналитические данные, специфичные для ниши КОТОРЫЕ АБСОЛЮТНО КОРРЕКТНЫЕ И УКАЗЫВАЮТ НА РЕАЛЬНУЮ НИШУ, ГДЕ УКАЗАНЫ ВСЕ РЕАЛЬНО И ПРАВИЛЬНО, УЧИТЫВАЯ ВСЕ ТОНКОСТИ {{NICHE}}.
|
||||
|
||||
ЭТАЛОННАЯ СТРУКТУРА JSON (Следуй ей неукоснительно):
|
||||
{{JSON_STRUCTURE}}
|
||||
|
||||
НО ТЫ ДОЛЖЕН УЧИТЫВАТЬ, ЧТО ТЫ НЕ ПРОСТО ДОЛЖЕН ПРИДУМЫВАТЬ ДАНННЫЕ, ТЫ ДОЛЖЕН КОНКРЕТНЫЕ ДАННЫЕ ОТПРАВЛЯТЬ, КОТОРЫЕ РЕАЛЬНО ПОКАЗЫВАЮТ НАСКОЛЬКО ОНИ РЕАЛЬНЫЕ, И ОТОБРАЖАЮТ РЕАЛЬНОСТЬ, ССЫЛАЯСЬ НА НАСТОЯЩИЕ ИСТОЧНИКИ, И СДЕЛАВ ОЧЕНЬ ГЛУБОКИЙ АНАЛИЗ
|
||||
""";
|
||||
|
||||
return template
|
||||
@@ -843,203 +788,10 @@ public class MarketingAnalysisService {
|
||||
.replace("{{AGE_INSTRUCTION}}", ageRangesInstruction)
|
||||
.replace("{{JSON_STRUCTURE}}", structureExampleJson);
|
||||
}
|
||||
private String buildJsonStructureExample() {
|
||||
return """
|
||||
{
|
||||
"report_title": "Стратегический анализ рынка: [Ниша]",
|
||||
"sources": [
|
||||
"[https://stat.gov.kz/](https://stat.gov.kz/)...",
|
||||
"[https://forbes.kz/](https://forbes.kz/)...",
|
||||
"[https://kapital.kz/](https://kapital.kz/)..."
|
||||
],
|
||||
"sections": {
|
||||
"market_overview": {
|
||||
"title": "Обзор рынка",
|
||||
"metrics": {
|
||||
"online_demand_index": "Высокий (8/10)",
|
||||
"online_potential": "Растущий (+15% YoY)",
|
||||
"demand_growth_rate_yoy": "+18.5%",
|
||||
"seasonality_coefficient_spring": "1.2",
|
||||
"seasonality_range": "Сентябрь - Май"
|
||||
},
|
||||
"monthly_online_index": {
|
||||
"january": 0.8, "february": 0.9, "march": 1.0, "april": 0.9, "may": 0.8,
|
||||
"june": 0.6, "july": 0.5, "august": 0.7, "september": 0.9,
|
||||
"october": 0.95, "november": 1.0, "december": 0.9
|
||||
},
|
||||
"geography": {
|
||||
"Алматы": 100,
|
||||
"Астана": 85,
|
||||
"Шымкент": 50
|
||||
},
|
||||
"conclusion": "Рынок демонстрирует устойчивый рост..."
|
||||
},
|
||||
"target_audience": {
|
||||
"title": "Портрет аудитории",
|
||||
"metrics": {
|
||||
"total_audience_size": "~450 000 чел.",
|
||||
"leading_segment_share": "Женщины 25-34",
|
||||
"above_average_income_share": "35%"
|
||||
},
|
||||
"income_by_age": {
|
||||
"25-34": 350000,
|
||||
"35-44": 420000
|
||||
},
|
||||
"segments": [
|
||||
{ "name": "Ядро (Core)", "share": "45%" },
|
||||
{ "name": "Потенциальные", "share": "30%" }
|
||||
],
|
||||
"age_structure": {
|
||||
"25-34": "45%",
|
||||
"35-44": "30%"
|
||||
},
|
||||
"conclusion": "Ключевой сегмент - платежеспособные специалисты..."
|
||||
},
|
||||
"competitive_environment": {
|
||||
"title": "Конкурентная среда",
|
||||
"metrics": {
|
||||
"leader_market_share": "35%",
|
||||
"competition_level": "Высокая",
|
||||
"ad_pressure": "Агрессивная",
|
||||
"market_leader_name": "Invictus"
|
||||
},
|
||||
"competitors": [
|
||||
{
|
||||
"name": "Invictus",
|
||||
"website": "invictus.kz",
|
||||
"visibility_score": 95,
|
||||
"key_strengths": ["Сильный бренд", "Сеть залов"],
|
||||
"price_segment": "Premium"
|
||||
},
|
||||
{
|
||||
"name": "1Fit",
|
||||
"website": "1fit.app",
|
||||
"visibility_score": 88,
|
||||
"key_strengths": ["Единый абонемент", "Удобное приложение"],
|
||||
"price_segment": "Aggregator"
|
||||
}
|
||||
],
|
||||
"conclusion": "Рынок консолидирован крупными игроками..."
|
||||
},
|
||||
"demand_and_behavior": {
|
||||
"title": "Поведение потребителей",
|
||||
"metrics": {
|
||||
"search_volume_growth": "+22%",
|
||||
"interactions_before_purchase": "5-7 касаний",
|
||||
"repeat_refusal_probability": "15%"
|
||||
},
|
||||
"search_query_types": {
|
||||
"Коммерческие": "45%",
|
||||
"Информационные": "30%"
|
||||
},
|
||||
"decision_factors": {
|
||||
"Цена": "Высокая важность",
|
||||
"Отзывы": "Средняя важность"
|
||||
},
|
||||
"reasons_for_refusal": {
|
||||
"Высокая цена": "40%",
|
||||
"Плохой сервис": "30%"
|
||||
},
|
||||
"conclusion": "Пользователи активно сравнивают цены..."
|
||||
},
|
||||
"customer_journey": {
|
||||
"title": "Воронка продаж",
|
||||
"funnel_metrics": {
|
||||
"ad_traffic": "15 000 кликов",
|
||||
"site_visitors": "12 000 (CTR 1.5%)",
|
||||
"leads": 450,
|
||||
"bookings": 200,
|
||||
"payments": 120
|
||||
},
|
||||
"conversion_rates": {
|
||||
"lead_to_booking": "45%",
|
||||
"booking_to_payment": "60%",
|
||||
"lead_to_payment_total": "27%"
|
||||
},
|
||||
"funnel_bottlenecks": {
|
||||
"lead_to_booking": "Долгий ответ менеджера",
|
||||
"booking_to_payment": "Нет рассрочки"
|
||||
},
|
||||
"conclusion": "Основная потеря лидов происходит на этапе..."
|
||||
},
|
||||
"acquisition_channels": {
|
||||
"title": "Каналы привлечения",
|
||||
"metrics": {
|
||||
"search_traffic_share": "30%",
|
||||
"average_lead_cost": "3500 KZT",
|
||||
"growth_potential": "TikTok/Reels"
|
||||
},
|
||||
"channel_shares": {
|
||||
"Instagram": "50%",
|
||||
"Google Ads": "20%",
|
||||
"TikTok": "15%"
|
||||
},
|
||||
"channel_efficiency": {
|
||||
"instagram": { "cost": "2500 KZT", "status": "High" },
|
||||
"google_search": { "cost": "4500 KZT", "status": "Medium" }
|
||||
},
|
||||
"conclusion": "Instagram остается самым эффективным каналом..."
|
||||
},
|
||||
"marketing_economics": {
|
||||
"title": "Юнит-экономика",
|
||||
"metrics": {
|
||||
"customer_acquisition_cost_cac": "3500 KZT",
|
||||
"repeat_sales_conversion": "30%",
|
||||
"roi": "250%"
|
||||
},
|
||||
"financial_performance": {
|
||||
"marketing_expenses": { "month": "1.5 млн KZT", "year": "18 млн KZT" },
|
||||
"clients": { "month": "~430", "year": "~5160" },
|
||||
"cac": { "month": "3500 KZT", "year": "3200 KZT" },
|
||||
"revenue_from_client": { "month": "25 000 KZT", "year": "300 000 KZT" },
|
||||
"profit_from_client": { "month": "7 500 KZT", "year": "90 000 KZT" }
|
||||
},
|
||||
"conclusion": "Модель масштабируема при сохранении CAC..."
|
||||
},
|
||||
"bottlenecks": {
|
||||
"title": "Узкие места",
|
||||
"statistics": {
|
||||
"landing_page_issues": "Отказы 60%",
|
||||
"slow_manager_responses": "30% потерь"
|
||||
},
|
||||
"details": {
|
||||
"landing": ["Медленная загрузка", "Слабый оффер"],
|
||||
"lead_management": ["Нет скриптов", "Звонок > 15 мин"],
|
||||
"retention": ["Нет программы лояльности"]
|
||||
},
|
||||
"conclusion": "Необходимо оптимизировать мобильную версию сайта..."
|
||||
},
|
||||
"recommendations": {
|
||||
"title": "План действий",
|
||||
"core_pillars": [
|
||||
{ "strategy": "Быстрый старт", "action": "Запустить таргет..." },
|
||||
{ "strategy": "Удержание", "action": "Внедрить систему бонусов..." }
|
||||
],
|
||||
"conclusion": "Рекомендуется начать с..."
|
||||
}
|
||||
}
|
||||
}
|
||||
""";
|
||||
}
|
||||
private boolean isPlaceholderCompetitorKey(String key) {
|
||||
if (key == null) {
|
||||
return false;
|
||||
}
|
||||
String k = key.trim().toLowerCase(Locale.ROOT);
|
||||
if (k.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
return k.startsWith("competitor_")
|
||||
|| "your_business".equals(k)
|
||||
|| "lead".equals(k)
|
||||
|| "payment".equals(k)
|
||||
|| "others".equals(k);
|
||||
}
|
||||
|
||||
private List<String> resolveCompetitorCompaniesForBenchmarks(MarketingAnalysisRequest request,
|
||||
Map<String, Object> researchPack,
|
||||
int limit) {
|
||||
int max = Math.max(5, Math.min(limit, 15));
|
||||
Map<String, Object> researchPack) {
|
||||
int max = Math.max(5, Math.min(5, 15));
|
||||
LinkedHashSet<String> names = new LinkedHashSet<>();
|
||||
|
||||
if (researchPack != null) {
|
||||
@@ -1090,7 +842,7 @@ public class MarketingAnalysisService {
|
||||
});
|
||||
|
||||
List<String> out = new ArrayList<>(names);
|
||||
int resultLimit = Math.min(out.size(), Math.max(limit, 3));
|
||||
int resultLimit = Math.min(out.size(), Math.max(5, 3));
|
||||
return out.subList(0, resultLimit);
|
||||
}
|
||||
|
||||
@@ -1164,12 +916,10 @@ public class MarketingAnalysisService {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Basic "humanization" of domain label.
|
||||
String cleaned = sld.replaceAll("[^a-z0-9\\-]+", "").replace('-', ' ').trim();
|
||||
if (cleaned.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
// Title-case first character
|
||||
return cleaned.substring(0, 1).toUpperCase(Locale.ROOT) + cleaned.substring(1);
|
||||
}
|
||||
|
||||
@@ -1181,9 +931,6 @@ public class MarketingAnalysisService {
|
||||
if (t.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Keep the left-most "brand-like" chunk: "Brand — ...", "Brand | ...", "Brand -
|
||||
// ..."
|
||||
String[] seps = new String[] { " — ", " - ", " | ", " – ", ":", "|", "—", "–" };
|
||||
for (String sep : seps) {
|
||||
int idx = t.indexOf(sep);
|
||||
@@ -1198,7 +945,6 @@ public class MarketingAnalysisService {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Trim common location tails: "... Алматы", "... Астана", "... Казахстан"
|
||||
t = t.replaceAll("\\s+(Алматы|Астана|Казахстан)\\s*$", "").trim();
|
||||
return t.isEmpty() ? null : t;
|
||||
}
|
||||
@@ -1213,7 +959,6 @@ public class MarketingAnalysisService {
|
||||
}
|
||||
String lower = t.toLowerCase(Locale.ROOT);
|
||||
|
||||
// Exclude obvious non-brands (directories/articles)
|
||||
String[] bad = new String[] {
|
||||
"топ", "рейтинг", "обзор", "лучшие", "лучших", "каталог", "список", "цены", "стоимость",
|
||||
"отзывы", "адрес", "телефон", "контакты", "как выбрать", "вакансии", "купить", "доставка"
|
||||
@@ -1224,65 +969,16 @@ public class MarketingAnalysisService {
|
||||
}
|
||||
}
|
||||
|
||||
// Too many words is usually an article title, not a brand
|
||||
if (t.split("\\s+").length > 5) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private void replacePlaceholderCompanyKeysIfNeeded(MarketingAnalysisResponseV2 parsed,
|
||||
List<String> competitorCompanies) {
|
||||
// 1. Базовые проверки на null
|
||||
if (parsed == null || competitorCompanies == null || competitorCompanies.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
if (parsed.getSections() == null || parsed.getSections().getCompetitiveEnvironment() == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
MarketingAnalysisResponseV2.CompetitiveEnvironment ce = parsed.getSections().getCompetitiveEnvironment();
|
||||
|
||||
// Получаем список конкурентов из DTO
|
||||
List<MarketingAnalysisResponseV2.CompetitorRow> rows = ce.getCompetitors();
|
||||
|
||||
if (rows != null && !rows.isEmpty()) {
|
||||
// Проверяем, есть ли заглушки хотя бы у одного элемента
|
||||
boolean hasPlaceholders = rows.stream()
|
||||
.anyMatch(row -> isPlaceholderCompetitorKey(row.getName()));
|
||||
|
||||
if (hasPlaceholders) {
|
||||
// Определяем, сколько элементов мы можем обновить (чтобы не выйти за пределы массивов)
|
||||
int n = Math.min(rows.size(), competitorCompanies.size());
|
||||
|
||||
for (int i = 0; i < n; i++) {
|
||||
MarketingAnalysisResponseV2.CompetitorRow row = rows.get(i);
|
||||
|
||||
// Если имя текущего конкурента - заглушка, меняем на реальное
|
||||
if (isPlaceholderCompetitorKey(row.getName())) {
|
||||
String realCompetitorName = competitorCompanies.get(i);
|
||||
row.setName(realCompetitorName);
|
||||
|
||||
// ЛОГИКА ДЛЯ САЙТА:
|
||||
// Если у нас нет сайта от ГПТ, или если реальное имя похоже на домен,
|
||||
// можно записать его и в website для надежности.
|
||||
if (row.getWebsite() == null || row.getWebsite().isEmpty() || row.getWebsite().equals("—")) {
|
||||
// Предполагаем, что в competitorCompanies могут лежать домены
|
||||
row.setWebsite(realCompetitorName);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public String generateAndSaveAnalysisV2(MarketingAnalysisRequest request, String userId) {
|
||||
try {
|
||||
// Генерируем анализ
|
||||
MarketingAnalysisResponseV2 analysisData = generateAnalysisV2(request);
|
||||
|
||||
// Создаем документ для сохранения
|
||||
MarketingAnalysisV2Document document = new MarketingAnalysisV2Document();
|
||||
document.setUserId(userId);
|
||||
document.setBusinessNiche(request.getBusinessNiche());
|
||||
@@ -1296,7 +992,6 @@ public class MarketingAnalysisService {
|
||||
document.setAnalysisType(request.getAnalysisType());
|
||||
document.setAnalysisData(analysisData);
|
||||
|
||||
// Сохраняем в MongoDB
|
||||
MarketingAnalysisV2Document saved = v2Repository.save(document);
|
||||
logger.info("Saved marketing analysis v2 with id: {}", saved.getId());
|
||||
return saved.getId();
|
||||
@@ -1350,11 +1045,10 @@ public class MarketingAnalysisService {
|
||||
marketOverview.setMetrics(moMetrics);
|
||||
|
||||
MarketingAnalysisResponseV2.MonthlyOnlineIndex monthlyOnlineIndex = new MarketingAnalysisResponseV2.MonthlyOnlineIndex();
|
||||
// Ставим 1.0, чтобы показать, что это Double, но не навязывать тренд фитнеса
|
||||
monthlyOnlineIndex.setJanuary(1.0); monthlyOnlineIndex.setFebruary(1.0); monthlyOnlineIndex.setMarch(1.0);
|
||||
monthlyOnlineIndex.setApril(1.0); monthlyOnlineIndex.setMay(1.0); monthlyOnlineIndex.setJune(1.0);
|
||||
monthlyOnlineIndex.setJuly(1.0); monthlyOnlineIndex.setAugust(1.0); monthlyOnlineIndex.setSeptember(1.0);
|
||||
monthlyOnlineIndex.setOctober(1.0); monthlyOnlineIndex.setNovember(1.0); monthlyOnlineIndex.setDecember(1.0);
|
||||
monthlyOnlineIndex.setJanuary(0.1); monthlyOnlineIndex.setFebruary(0.1); monthlyOnlineIndex.setMarch(0.1);
|
||||
monthlyOnlineIndex.setApril(1.0); monthlyOnlineIndex.setMay(0.1); monthlyOnlineIndex.setJune(0.1);
|
||||
monthlyOnlineIndex.setJuly(0.1); monthlyOnlineIndex.setAugust(0.1); monthlyOnlineIndex.setSeptember(0.1);
|
||||
monthlyOnlineIndex.setOctober(0.1); monthlyOnlineIndex.setNovember(0.1); monthlyOnlineIndex.setDecember(0.1);
|
||||
marketOverview.setMonthlyOnlineIndex(monthlyOnlineIndex);
|
||||
|
||||
Map<String, Integer> geography = new java.util.LinkedHashMap<>();
|
||||
@@ -1387,13 +1081,13 @@ public class MarketingAnalysisService {
|
||||
java.util.Map<String, Integer> incomeByAge = new java.util.LinkedHashMap<>();
|
||||
// Ставим 0, чтобы ИИ понял, что нужны числа (Integer), а не строки
|
||||
incomeByAge.put("18-24", 0); incomeByAge.put("25-34", 0);
|
||||
incomeByAge.put("35-44", 0); incomeByAge.put("45+", 0);
|
||||
incomeByAge.put("35-44", 0); incomeByAge.put("45-54", 0);
|
||||
incomeByAge.put("55+", 0);
|
||||
targetAudience.setIncomeByAge(incomeByAge);
|
||||
|
||||
targetAudience.setConclusion("[[ВЫВОД_О_КЛИЕНТАХ]]");
|
||||
sections.setTargetAudience(targetAudience);
|
||||
|
||||
// 3. Competitive Environment
|
||||
MarketingAnalysisResponseV2.CompetitiveEnvironment competitiveEnvironment = new MarketingAnalysisResponseV2.CompetitiveEnvironment();
|
||||
competitiveEnvironment.setTitle("Конкурентный ландшафт");
|
||||
|
||||
@@ -1405,7 +1099,6 @@ public class MarketingAnalysisService {
|
||||
competitiveEnvironment.setMetrics(ceMetrics);
|
||||
|
||||
java.util.List<MarketingAnalysisResponseV2.CompetitorRow> competitorRows = new java.util.ArrayList<>();
|
||||
// Здесь мы оставляем список пустым или с заглушкой, чтобы ИИ заполнил сам на основе списка competitorCompanies
|
||||
MarketingAnalysisResponseV2.CompetitorRow row = new MarketingAnalysisResponseV2.CompetitorRow();
|
||||
row.setName("[[ИМЯ_КОНКУРЕНТА_1]]");
|
||||
row.setWebsite("[[САЙТ_ИЛИ_ЗАГЛУШКА]]");
|
||||
@@ -1513,7 +1206,7 @@ public class MarketingAnalysisService {
|
||||
|
||||
MarketingAnalysisResponseV2.FinancialPerformance financialPerformance = new MarketingAnalysisResponseV2.FinancialPerformance();
|
||||
// ВАЖНО: Мы показываем, что month и year - это String поля для значений
|
||||
financialPerformance.setMarketingExpenses(new MarketingAnalysisResponseV2.MonthYearValue("[[РАСХОД_МЕСЯЦ]]", "[[РАСХОД_ГОД]]"));
|
||||
financialPerformance.setMarketingExpenses(new MarketingAnalysisResponseV2.MonthYearValue("[[РАСХОД_МЕСЯЦ(всегда ставь 100 000, но нужно выводить информацию отталкиваясь от этой суммы, и ты должен выдавать только сумму]]", "[[РАСХОД_ГОД]]"));
|
||||
financialPerformance.setClients(new MarketingAnalysisResponseV2.MonthYearValue("[[КЛИЕНТОВ_МЕСЯЦ]]", "[[КЛИЕНТОВ_ГОД]]"));
|
||||
financialPerformance.setCac(new MarketingAnalysisResponseV2.MonthYearValue("[[CAC_МЕСЯЦ]]", "[[CAC_ГОД]]"));
|
||||
financialPerformance.setRevenueFromClient(new MarketingAnalysisResponseV2.MonthYearValue("[[ВЫРУЧКА_МЕСЯЦ]]", "[[ВЫРУЧКА_ГОД]]"));
|
||||
|
||||
Reference in New Issue
Block a user