This commit is contained in:
arys
2026-02-22 16:16:17 +05:00
parent 470a782d48
commit 0ba65b0de9
@@ -56,6 +56,11 @@ public class MarketingAnalysisV3Service {
Map<String, Object> researchPack = executeDeepResearch(request);
saveResearchMetaData(docId, researchPack);
// Защита: Если поиск упал, но мы продолжаем, чтобы выдать хоть что-то
if (!hasValidData(researchPack)) {
log.warn("[Analysis ID: {}] SEARCH FAILED. Forcing AI fallback protocol.", docId);
}
log.info("[Analysis ID: {}] Step 2/3: Building Prompts", docId);
String systemPrompt = buildKazakhstanSystemPrompt();
String userPrompt = buildDataDrivenUserPrompt(request, researchPack);
@@ -75,15 +80,25 @@ public class MarketingAnalysisV3Service {
}
}
private String sanitizeInput(String input) {
if (input == null || input.isBlank()) {
return "";
private boolean hasValidData(Map<String, Object> researchPack) {
if (researchPack == null || researchPack.isEmpty()) return false;
for (Map.Entry<String, Object> entry : researchPack.entrySet()) {
if (entry.getKey().equals("generatedAt")) continue;
Object value = entry.getValue();
if (value instanceof Map) {
Map<?, ?> mapValue = (Map<?, ?>) value;
if ("OK".equals(mapValue.get("status"))) {
Object items = mapValue.get("items");
if (items instanceof List && !((List<?>) items).isEmpty()) return true;
}
}
}
return input.trim()
.replaceAll("\\s+", " ")
.replaceAll("(?i)залл\\b", "зал")
.replaceAll("(?i)салонн\\b", "салон")
.replaceAll("(?i)бизнес\\b", "бизнес");
return false;
}
private String sanitizeInput(String input) {
if (input == null || input.isBlank()) return "";
return input.trim().replaceAll("\\s+", " ").replaceAll("(?i)залл\\b", "зал").replaceAll("(?i)салонн\\b", "салон").replaceAll("(?i)бизнес\\b", "бизнес");
}
private Map<String, Object> executeDeepResearch(MarketingAnalysisV3Request request) {
@@ -101,9 +116,7 @@ public class MarketingAnalysisV3Service {
String geoContext = String.join(" ", cities);
String topic = (niche + " " + product).trim();
if (topic.isEmpty()) {
topic = sanitizeInput(request.getProductDescription());
}
if (topic.isEmpty()) topic = sanitizeInput(request.getProductDescription());
String kzSites = "(site:stat.gov.kz OR site:kapital.kz OR site:kursiv.media OR site:forbes.kz OR site:ranking.kz)";
String retailSites = "(site:2gis.kz OR site:kaspi.kz OR site:kolesa.kz OR site:krisha.kz OR site:chocofood.kz OR site:instagram.com)";
@@ -117,10 +130,8 @@ public class MarketingAnalysisV3Service {
queries.parallelStream().forEach(q -> {
String safeMongoKey = q.replace(".", "_").replace("$", "");
try {
log.debug("Executing search query: {}", q);
pack.put(safeMongoKey, searchService.search(q));
} catch (Exception e) {
log.warn("Search failed for query: {}. Error: {}", q, e.getMessage());
pack.put(safeMongoKey, Map.of("error", e.getMessage(), "status", "ERROR"));
}
});
@@ -136,48 +147,32 @@ public class MarketingAnalysisV3Service {
while (attempts < maxAttempts) {
try {
log.info("[Analysis ID: {}] AI Generation attempt {}/{}", docId, attempts + 1, maxAttempts);
String response = aiService.generateWithInstructionWithModel(
"{}",
userPrompt,
"ru",
highIntelligenceModel,
systemPrompt,
16000,
240000L
);
String response = aiService.generateWithInstructionWithModel("{}", userPrompt, "ru", highIntelligenceModel, systemPrompt, 16000, 240000L);
String cleaned = cleanJson(response);
if (cleaned != null && cleaned.startsWith("{") && cleaned.endsWith("}")) {
objectMapper.readTree(cleaned);
return cleaned;
} else {
lastError = "Response is not a valid JSON structure";
log.warn("[Analysis ID: {}] Invalid JSON received from AI", docId);
}
} catch (JsonProcessingException e) {
lastError = "JSON Parse Error: " + e.getMessage();
log.warn("[Analysis ID: {}] Failed to parse AI JSON response", docId);
} catch (Exception e) {
lastError = "API Error: " + e.getMessage();
log.warn("[Analysis ID: {}] AI API Call failed: {}", docId, e.getMessage());
}
attempts++;
try { Thread.sleep(3000L * attempts); } catch (InterruptedException ignored) {}
}
throw new RuntimeException("Failed to generate valid JSON after " + maxAttempts + " attempts. Last error: " + lastError);
throw new RuntimeException("Failed to generate valid JSON: " + lastError);
}
private MarketingAnalysisV3Result parseAndValidateResult(String json) throws Exception {
ObjectMapper safeMapper = this.objectMapper.copy();
// ВАЖНО: Мы игнорируем старые поля, которые ИИ может по привычке вернуть
safeMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
return safeMapper.readValue(json, MarketingAnalysisV3Result.class);
}
private String cleanJson(String response) {
if (response == null || response.trim().isEmpty()) {
return null;
}
if (response == null || response.trim().isEmpty()) return null;
String cleaned = response.trim();
int firstBrace = cleaned.indexOf("{");
int lastBrace = cleaned.lastIndexOf("}");
@@ -200,10 +195,8 @@ public class MarketingAnalysisV3Service {
repository.findById(id).ifPresent(doc -> {
doc.setResearchMetaData(researchPack);
repository.save(doc);
log.debug("[Analysis ID: {}] Research metadata successfully saved to DB", id);
});
} catch (Exception e) {
log.error("[Analysis ID: {}] CRITICAL: Failed to save research metadata to MongoDB. Check for invalid characters in keys. Error: {}", id, e.getMessage());
throw e;
}
}
@@ -236,18 +229,15 @@ public class MarketingAnalysisV3Service {
private String buildKazakhstanSystemPrompt() {
return """
РОЛЬ: Ты — Chief Data Officer и Senior Стратег Big 4 (PwC, McKinsey), эксперт по рынку Казахстана.
Твоя задача — не просто сгенерировать JSON, а подготовить ИДЕАЛЬНЫЙ датасет для интерактивного аналитического дашборда на фронтенде (React/Vue).
От твоих данных будут строиться реальные графики (Radar, Scatter, Pie, Line charts). Данные должны быть математически корректны и логически связаны.
РОЛЬ: Ты — Chief Data Officer и Senior Стратег Big 4.
Твоя задача — сгенерировать ИДЕАЛЬНЫЙ датасет для дашборда.
КРИТИЧЕСКИЕ ПРАВИЛА (ANTI-HALLUCINATION PROTOCOL):
1. АБСОЛЮТНАЯ РУСИФИКАЦИЯ: Все статусы, уровни, тренды и перечисления (businessStage, competitionLevel, demandTrend, intensityLabel) СТРОГО НА РУССКОМ ЯЗЫКЕ. Никаких 'JUST_STARTING', 'HIGH', 'NONE'.
2. ГЕОГРАФИЯ И ГОРОДА: Ты ОБЯЗАН включить КАЖДЫЙ город, переданный в запросе, в блоки аналитики (1_market_landscape и 2_geo_structure). Пропуск даже одного города — критический провал системы.
3. КОНКУРЕНТЫ: В блоке `3_competitor_map` ОБЯЗАНО быть строго от 5 до 10 реально существующих компаний из SEARCH EVIDENCE. Ровно 3 — ЗАПРЕЩЕНО.
4. ЧИСЛА, А НЕ СЛОВА: Если фронт ждет int или double (например, avgMonthlySearches, avgCycleDays), выдавай СТРОГО числа. Никаких слов "высокая" или "короткий".
5. НИКАКИХ NULL И ПУСТОТ: Заполни абсолютно все поля. Используй экстраполяцию рынка РК (Kaspi, 2GIS, менталитет), если точных данных нет.
6. ЛОГИКА ГРАФИКОВ: Суммы процентов (например, в content_profile или starDistribution) должны давать ровно 100%.
7. РЕАЛИСТИЧНОСТЬ ДАННЫХ (КРИТИЧНО): Избегай круглых чисел и ленивых средних значений (типа ровно 50% или везде 0). Генерируй реалистичный математический разброс для показателей, основываясь на данных парсинга.
КРИТИЧЕСКИЕ ПРАВИЛА (СМЕРТЕЛЬНО ВАЖНО):
1. НОВЫЕ КЛЮЧИ JSON: Используй ТОЛЬКО ключи из эталонного JSON ниже. КАТЕГОРИЧЕСКИ ЗАПРЕЩЕНО использовать старые ключи (avgTextLength, ctaFrequency, promoFrequency и т.д.). Используй ТОЛЬКО новые (avgTextLengthCharacters, ctaUsagePercent, promoUsagePercent).
2. АБСОЛЮТНО НИКАКИХ NULL: Заполни КАЖДОЕ поле JSON. Если точной цифры нет — сгенерируй реалистичную оценку. НИ ОДНО ЗНАЧЕНИЕ НЕ ДОЛЖНО БЫТЬ null.
3. ПРОТОКОЛ ВЫЖИВАНИЯ: Если SEARCH EVIDENCE пуст или содержит ошибки, ТЕБЕ ЗАПРЕЩАЕТСЯ писать об этом. Ты ОБЯЗАН сгенерировать 100% валидный JSON, используя метод Ферми и знания рынка.
4. РУСИФИКАЦИЯ: Все текстовые значения (businessStage, demandTrend) СТРОГО на русском языке.
5. ГЕОГРАФИЯ: Ты ОБЯЗАН включить КАЖДЫЙ город, переданный в запросе, в блоки 1_market_landscape и 2_geo_structure.
""";
}
@@ -273,55 +263,29 @@ public class MarketingAnalysisV3Service {
РАЗВЕДДАННЫЕ (SEARCH EVIDENCE):
%s
ОБЯЗАТЕЛЬНЫЕ ЦЕЛЕВЫЕ ГОРОДА (СТРОГО ИСПОЛЬЗУЙ ВСЕ ГОРОДА В АНАЛИЗЕ!): %s
ЦЕЛЕВЫЕ ГОРОДА: %s
ИНСТРУКЦИИ ДЛЯ ПОДГОТОВКИ ДАННЫХ К ДАШБОРДУ (СТРОГО):
ИНСТРУКЦИИ (СТРОГО СОБЛЮДАТЬ НОВЫЕ КЛЮЧИ БЕЗ ЕДИНОГО NULL):
[0_executive_summary] -> Виджеты KPI
- Все текстовые поля (businessStage, geography, competitionLevel, demandTrend) СТРОГО НА РУССКОМ.
- averageNicheEr: верни реалистичное число (double, например 0.045).
[4_content_profile]
- avgTextLengthCharacters: число (int).
- ctaUsagePercent: процент (double).
[1_market_landscape] -> Pie Chart (Платформы) и Heatmap (Города)
- Распредели игроков по платформам адекватно.
- В cityDistribution ОБЯЗАТЕЛЬНО укажи распределение по КАЖДОМУ целевому городу.
[6_reputation_analysis]
- avgOwnerResponseTimeHours: часы (int).
[2_geo_structure] -> Сравнительная гистограмма
- В cityComparison ОБЯЗАНЫ быть ВСЕ целевые города с метриками.
[7_behavioral_pattern]
- promoUsagePercent, directBookingPercent, priceVisibilityPercent: (double).
- avgCycleDays: (int).
[3_competitor_map] -> Scatter Plot (Пузырьковая диаграмма)
- СТРОГО 5-10 РЕАЛЬНЫХ конкурентов. Меньше 5 недопустимо.
- ER, followers, postsPerMonth должны быть числовыми и реалистичными.
[8_search_demand]
- avgMonthlySearches: точное число запросов (int).
[4_content_profile] -> Donut Chart
- Сумма всех Percent строго 100.0. Сделай реалистичный разброс (например 35, 20, 15, 25, 5).
- avgTextLengthCharacters: верни число символов (int, например 350).
- ctaUsagePercent: процент постов с призывом (double, например 85.5).
[9_user_positioning]
- Если DTO smmStatus == "NONE": mode="BENCHMARK", userMetrics=0, marketMetrics=реальные числа.
- Если DTO smmStatus != "NONE": mode="COMPARISON", userMetrics=оценка, marketMetrics=реальные числа.
[5_competition_intensity] -> Gauge Chart (Спидометр)
- ciiIndex: 0-100.
- intensityLabel: СТРОГО НА РУССКОМ (например "Высокая", "Свободный рынок").
[6_reputation_analysis] -> Bar Chart (Рейтинги)
- starDistribution: сумма долей строго 100.0. Имитируй реальные отзывы (много 5, немного 1, почти нет 2 и 3).
- avgOwnerResponseTimeHours: часы (int, например 2 или 24).
[7_behavioral_pattern] -> Горизонтальный Bar Chart и Карточки
- Показатели (promoUsagePercent и т.д.) верни в процентах (double, 0.0 - 100.0). Сделай цифры разными и реалистичными.
- avgCycleDays: цикл сделки в днях (int, например 7).
[8_search_demand] -> Line Chart (Сезонность)
- avgMonthlySearches: точное число запросов (int, например 15000). Никаких слов!
- seasonality: ровно 12 объектов. Периоды строго на русском (Январь, Февраль...).
[9_user_positioning] -> Radar Chart (Паутинка) - ВАЖНАЯ ЛОГИКА:
- Если DTO smmStatus == "NONE": поле mode СТРОГО "BENCHMARK". userMetrics везде 0. marketMetrics заполни РЕАЛЬНЫМИ средними требованиями ниши (от 0 до 100, с разбросом, например activity 75, engagement 45, reputation 80).
- Если DTO smmStatus != "NONE": поле mode СТРОГО "COMPARISON". Оцени userMetrics на основе данных, marketMetrics — реалистичное среднее по рынку.
- strategicFocus: Напиши 3 конкретных шага (стратегический фокус).
[10_structured_conclusions] & [11_smm_strategy_rationale] -> Текстовые выводы
- МИНИМУМ 50 СЛОВ в блоке 11. Обоснуй SMM стратегию глубоко, опираясь на конкретные числа из графиков.
ЭТАЛОННЫЙ JSON (СТРОГО СОБЛЮДАЙ ФОРМАТ, КЛЮЧИ И ТИПЫ ДАННЫХ):
ЭТАЛОННЫЙ JSON (СКОПИРУЙ КЛЮЧИ ТОЧЬ-В-ТОЧЬ, ЗАПОЛНИ ЧИСЛАМИ, НИКАКИХ NULL):
%s
""".formatted(requestJson, evidenceJson, targetCities, schemaTemplate);
} catch (Exception e) {
@@ -333,18 +297,18 @@ public class MarketingAnalysisV3Service {
return """
{
"0_executive_summary": {
"businessStage": "СТРОГО НА РУССКОМ (например: 'На этапе запуска', 'Действующий бизнес')",
"geography": "СТРОГО НА РУССКОМ (например: 'Один город', 'Вся страна')",
"businessStage": "СТРОГО НА РУССКОМ",
"geography": "СТРОГО НА РУССКОМ",
"activeCompetitors": 0,
"competitionLevel": "СТРОГО НА РУССКОМ (например: 'Высокий', 'Средний')",
"competitionLevel": "СТРОГО НА РУССКОМ",
"averageNicheEr": 0.0,
"averageRating": 0.0,
"demandTrend": "СТРОГО НА РУССКОМ (например: 'Растущий', 'Стабильный')",
"demandTrend": "СТРОГО НА РУССКОМ",
"keyFigures": ["string", "string", "string"]
},
"1_market_landscape": {
"activePlayersByPlatform": {"Instagram": 0, "TikTok": 0, "GoogleMaps": 0},
"cityDistribution": {"Город 1": 0, "Город 2": 0},
"cityDistribution": {"Город 1": 0},
"nicheReputationLevel": 0.0
},
"2_geo_structure": {
@@ -376,7 +340,7 @@ public class MarketingAnalysisV3Service {
},
"5_competition_intensity": {
"ciiIndex": 0,
"intensityLabel": "СТРОГО НА РУССКОМ (например: 'Высокая плотность')",
"intensityLabel": "СТРОГО НА РУССКОМ",
"contributingFactors": ["string"]
},
"6_reputation_analysis": {
@@ -415,11 +379,11 @@ public class MarketingAnalysisV3Service {
"9_user_positioning": {
"marketMetrics": {"activity": 0, "engagement": 0, "video": 0, "reputation": 0, "frequency": 0},
"userMetrics": {"activity": 0, "engagement": 0, "video": 0, "reputation": 0, "frequency": 0},
"mode": "BENCHMARK или COMPARISON",
"mode": "BENCHMARK",
"strategicFocus": ["string", "string", "string"]
},
"10_structured_conclusions": ["string", "string", "string"],
"11_smm_strategy_rationale": "МИНИМУМ 50 СЛОВ. Экспертный текст, связывающий данные графиков с выбором SMM."
"11_smm_strategy_rationale": "МИНИМУМ 50 СЛОВ. Обоснование стратегии."
}
""";
}