This commit is contained in:
arys
2026-02-28 16:28:37 +05:00
parent fd0207b105
commit 369c85430f
2 changed files with 165 additions and 113 deletions
@@ -38,7 +38,6 @@ public class MarketingAnalysisV3Controller {
private final MinIOService minIOService;
private final JwtService jwtService;
// ЕДИНЫЙ МЕТОД ДЛЯ ИЗВЛЕЧЕНИЯ USER ID ИЗ JWT (КАК В СТАРОМ КОНТРОЛЛЕРЕ)
private String extractUserIdFromHeader(String authHeader) {
if (authHeader == null || authHeader.isEmpty()) {
return null;
@@ -118,10 +117,6 @@ public class MarketingAnalysisV3Controller {
// БЛОК 2: СТРАТЕГИЯ (СКОРИНГ, ЛОГОТИПЫ, МЕДИА)
// ==========================================
/**
* ПРЕВЬЮ СТРАТЕГИИ: Вызывается, когда юзер открывает раздел Стратегии.
* Моментально возвращает идеальную модель на основе ответов опросника.
*/
@GetMapping("/{analysisId}/strategy-preview")
public ResponseEntity<?> previewStrategy(
@RequestHeader(value = "Authorization", required = false) String authHeader,
@@ -133,16 +128,14 @@ public class MarketingAnalysisV3Controller {
try {
Optional<MarketingAnalysisV3Document> analysisOpt = analysisService.getAnalysisById(analysisId);
if (analysisOpt.isEmpty()) return notFoundResponse("Анализ не найден");
if (!analysisOpt.get().getUserId().equals(userId)) return forbiddenResponse();
// Вызываем математику из сервиса напрямую
StrategyModel recommendedModel = strategyService.calculateBestScoringModel(analysisOpt.get().getRequestData());
Map<String, Object> responseData = Map.of(
"recommendedModel", recommendedModel.name(),
"modelTitle", recommendedModel.getTitle(),
"description", "На основе ваших ответов ИИ рекомендует эту стратегию для максимального результата."
"description", "ИИ автоматически подберет идеальные платформы и длительность контент-плана для этой стратегии."
);
return ResponseEntity.ok(ApiResponse.success("Рекомендация сформирована", responseData));
@@ -152,30 +145,23 @@ public class MarketingAnalysisV3Controller {
}
}
/**
* ГЕНЕРАЦИЯ СТРАТЕГИИ: Принимает настройки и файл логотипа (опционально)
* ИСПОЛЬЗУЕТ JWT ДЛЯ АВТОРИЗАЦИИ (БЕЗ РУЧНОГО USER-ID)
*/
@PostMapping(value = "/{analysisId}/strategy/generate", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<?> generateStrategy(
@RequestHeader(value = "Authorization", required = false) String authHeader,
@PathVariable String analysisId,
@RequestPart("request") @Valid MarketingStrategyRequest request,
@RequestPart(value = "request", required = false) MarketingStrategyRequest request,
@RequestPart(value = "logo", required = false) MultipartFile logoFile
) {
// Достаем ID пользователя из JWT токена
String userId = extractUserIdFromHeader(authHeader);
if (userId == null) return unauthorizedResponse();
try {
// Проверяем доступ к анализу
Optional<MarketingAnalysisV3Document> analysisOpt = analysisService.getAnalysisById(analysisId);
if (analysisOpt.isEmpty()) return notFoundResponse("Анализ не найден");
if (!analysisOpt.get().getUserId().equals(userId)) return forbiddenResponse();
String logoFilename = null;
// Если юзер прикрепил логотип, сохраняем его в MinIO
if (logoFile != null && !logoFile.isEmpty()) {
String originalExt = logoFile.getOriginalFilename() != null && logoFile.getOriginalFilename().contains(".") ?
logoFile.getOriginalFilename().substring(logoFile.getOriginalFilename().lastIndexOf(".")) : ".png";
@@ -184,15 +170,19 @@ public class MarketingAnalysisV3Controller {
log.info("Логотип клиента успешно загружен в MinIO: {}", logoFilename);
}
// Запускаем полную генерацию, передавая userId из токена
// Защита от NullPointerException, если фронтенд вообще не прислал объект request
if (request == null) {
request = new MarketingStrategyRequest();
}
MarketingStrategy strategy = strategyService.generateStrategy(analysisId, request, userId, logoFilename);
Map<String, String> responseData = Map.of(
"strategyId", strategy.getId(),
"status", strategy.getStatus(),
"message", "Генерация стратегии и медиафайлов успешно запущена"
"message", "Генерация запущена. ИИ самостоятельно анализирует нишу для выбора лучших платформ и длительности кампании."
);
return ResponseEntity.accepted().body(ApiResponse.success("Запущено", responseData));
return ResponseEntity.accepted().body(ApiResponse.success("Автономная генерация запущена", responseData));
} catch (Exception e) {
log.error("Failed to generate strategy for analysis: {}", analysisId, e);
@@ -200,9 +190,6 @@ public class MarketingAnalysisV3Controller {
}
}
/**
* ПОЛУЧЕНИЕ СТРАТЕГИИ: Для поллинга (проверки статуса) и отображения готового плана
*/
@GetMapping("/strategy/{strategyId}")
public ResponseEntity<?> getStrategyById(
@RequestHeader(value = "Authorization", required = false) String authHeader,
@@ -4,7 +4,6 @@ import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import kz.konturai.parser.dto.MarketingStrategyRequest;
import kz.konturai.parser.dto.MarketingAnalysisV3Request;
import kz.konturai.parser.dto.StatusHistoryEntry;
import kz.konturai.parser.enums.StrategyModel;
import kz.konturai.parser.model.MarketingAnalysisV3Document;
@@ -48,7 +47,6 @@ public class MarketingStrategyV3Service {
@Value("${image.generation.delayBetweenRequestsMs:3000}")
private long delayBetweenRequestsMs;
// ДОБАВЛЕННЫЙ МЕТОД, КОТОРЫЙ ИСКАЛ КОНТРОЛЛЕР
public Optional<MarketingStrategy> getStrategyById(String id) {
return repository.findById(id);
}
@@ -59,18 +57,17 @@ public class MarketingStrategyV3Service {
MarketingStrategy strategy = new MarketingStrategy(analysisId);
strategy.setUserId(userId);
strategy.setDurationWeeks(request.getDurationWeeks() != null ? request.getDurationWeeks() : 4);
strategy.setPriorityPlatforms(request.getPriorityPlatforms());
strategy.setDurationWeeks(4); // Дефолт, ИИ его перезапишет
strategy.setPriorityPlatforms(new ArrayList<>());
strategy.setStatus("queued");
// Сохраняем имя файла логотипа в strategyData, чтобы асинхронный процесс мог его скачать
Map<String, Object> initialData = new HashMap<>();
if (uploadedLogoFilename != null && !uploadedLogoFilename.isEmpty()) {
initialData.put("clientLogoFilename", uploadedLogoFilename);
}
strategy.setStrategyData(initialData);
addStatusHistoryEntry(strategy, "queued", "Запуск генерации стратегии");
addStatusHistoryEntry(strategy, "queued", "Запуск генерации. ИИ подбирает оптимальные платформы...");
strategy = repository.save(strategy);
processStrategyGenerationAsync(strategy.getId(), analysisDoc, strategy);
@@ -82,43 +79,50 @@ public class MarketingStrategyV3Service {
public void processStrategyGenerationAsync(String strategyId, MarketingAnalysisV3Document analysis, MarketingStrategy strategy) {
try {
strategy.setStatus("processing");
addStatusHistoryEntry(strategy, "processing", "Расчет баллов и генерация глубокого контент-плана...");
addStatusHistoryEntry(strategy, "processing", "Расчет баллов, выбор платформ и генерация контент-плана...");
repository.save(strategy);
// 1. Считаем лучшую модель
StrategyModel bestModel = calculateBestScoringModel(analysis.getRequestData());
log.info("[Strategy ID: {}] Выбрана модель: {}", strategyId, bestModel.name());
// 2. Генерируем JSON
String systemPrompt = buildSystemPrompt(bestModel);
String userPrompt = buildUserPrompt(analysis, strategy, bestModel);
String userPrompt = buildUserPrompt(analysis, bestModel);
String rawResponse = aiService.generateWithInstructionWithModel("{}", userPrompt, "ru", highIntelligenceModel, systemPrompt, 16000, 240000L);
String jsonResponse = extractCleanJson(rawResponse);
Map<String, Object> strategyData = objectMapper.readValue(jsonResponse, new TypeReference<>() {});
// Сохраняем старые данные (например, clientLogoFilename) перед перезаписью
if (strategy.getStrategyData() != null && strategy.getStrategyData().containsKey("clientLogoFilename")) {
strategyData.put("clientLogoFilename", strategy.getStrategyData().get("clientLogoFilename"));
if (jsonResponse == null) {
throw new IllegalStateException("AI не вернул валидный JSON");
}
// 3. Парсим ответ
Map<String, Object> strategyData = objectMapper.readValue(jsonResponse, new TypeReference<>() {});
if (strategy.getStrategyData() != null) {
strategy.getStrategyData().forEach(strategyData::putIfAbsent);
}
// 4. Заполняем сущность
populateStrategyEntity(strategy, strategyData);
repository.save(strategy);
// 5. Генерируем медиа НАДЕЖНЫМ способом из V1
generateMediaAssets(strategy, analysis);
strategy.setStatus("completed");
strategy.setCompletedAt(LocalDateTime.now());
addStatusHistoryEntry(strategy, "completed", "Стратегия и все медиа успешно созданы");
addStatusHistoryEntry(strategy, "completed", "Стратегия и все медиа успешно созданы!");
repository.save(strategy);
} catch (Exception e) {
log.error("Ошибка при генерации стратегии {}: {}", strategyId, e.getMessage(), e);
markAsFailed(strategyId, e.getMessage());
markAsFailed(strategyId, "Ошибка при генерации стратегии: " + e.getMessage());
}
}
// ТЕПЕРЬ PUBLIC, ЧТОБЫ КОНТРОЛЛЕР МОГ ЕГО ВЫЗВАТЬ ДЛЯ ПРЕВЬЮ
public StrategyModel calculateBestScoringModel(MarketingAnalysisV3Request req) {
public StrategyModel calculateBestScoringModel(kz.konturai.parser.dto.MarketingAnalysisV3Request req) {
int entry = 0, authority = 0, trust = 0, conversion = 0;
if (req.getCustomerBehaviors() != null) {
@@ -168,6 +172,13 @@ public class MarketingStrategyV3Service {
private void populateStrategyEntity(MarketingStrategy strategy, Map<String, Object> strategyData) {
strategy.setStrategyData(strategyData);
if (strategyData.containsKey("recommendedDurationWeeks")) {
strategy.setDurationWeeks((Integer) strategyData.get("recommendedDurationWeeks"));
}
if (strategyData.containsKey("recommendedPlatforms")) {
strategy.setPriorityPlatforms((List<String>) strategyData.get("recommendedPlatforms"));
}
List<Map<String, Object>> weeksMap = (List<Map<String, Object>>) strategyData.get("weeklyPlans");
List<MarketingStrategy.WeeklyPlan> weeklyPlans = new ArrayList<>();
if (weeksMap != null) {
@@ -184,14 +195,24 @@ public class MarketingStrategyV3Service {
List<Map<String, Object>> calendarMap = (List<Map<String, Object>>) strategyData.get("postCalendar");
List<MarketingStrategy.PostCalendarItem> postCalendar = new ArrayList<>();
LocalDateTime startDate = LocalDateTime.now().plusDays(1);
if (calendarMap != null) {
for (Map<String, Object> c : calendarMap) {
MarketingStrategy.PostCalendarItem item = new MarketingStrategy.PostCalendarItem();
try {
item.setPublishDate(LocalDateTime.parse((String) c.get("publishDate"), DateTimeFormatter.ISO_LOCAL_DATE_TIME));
} catch (Exception e) {
item.setPublishDate(LocalDateTime.now().plusDays(postCalendar.size() + 1));
// Безопасный парсинг даты как в V1
String dateStr = (String) c.get("publishDate");
if (dateStr != null) {
try {
item.setPublishDate(LocalDateTime.parse(dateStr, DateTimeFormatter.ISO_LOCAL_DATE_TIME));
} catch (Exception e) {
item.setPublishDate(startDate.plusDays(postCalendar.size()));
}
} else {
item.setPublishDate(startDate.plusDays(postCalendar.size()));
}
item.setPlatform((String) c.get("platform"));
item.setContentType((String) c.get("contentType"));
item.setTheme((String) c.get("theme"));
@@ -199,33 +220,30 @@ public class MarketingStrategyV3Service {
item.setHashtags((List<String>) c.get("hashtags"));
item.setPublishTime((String) c.get("publishTime"));
if (c.containsKey("videoGenerationPrompt")) {
item.setVideoGenerationPrompt((String) c.get("videoGenerationPrompt"));
}
if (c.containsKey("imageGenerationPrompt")) {
item.setImageFilename((String) c.get("imageGenerationPrompt"));
}
postCalendar.add(item);
}
}
strategy.setPostCalendar(postCalendar);
}
private void generateMediaAssets(MarketingStrategy strategy, MarketingAnalysisV3Document analysis) {
String businessContext = extractBusinessContext(analysis);
// ==========================================
// НАДЕЖНАЯ ГЕНЕРАЦИЯ МЕДИА (ПОРТИРОВАНО ИЗ V1)
// ==========================================
// 1. Загружаем логотип клиента, если он был передан
private void generateMediaAssets(MarketingStrategy strategy, MarketingAnalysisV3Document analysis) {
String businessContext = getBusinessContext(analysis);
String brandName = analysis.getRequestData().getProductName();
byte[] clientLogoBytes = null;
if (strategy.getStrategyData() != null && strategy.getStrategyData().containsKey("clientLogoFilename")) {
String logoFilename = (String) strategy.getStrategyData().get("clientLogoFilename");
try {
InputStream logoStream = minIOService.downloadFile(logoFilename);
clientLogoBytes = logoStream.readAllBytes();
logoStream.close();
log.info("Успешно загружен референсный логотип клиента для интеграции: {}", logoFilename);
log.info("Загружен логотип: {}", logoFilename);
} catch (Exception e) {
log.error("Не удалось скачать логотип клиента из MinIO {}: {}", logoFilename, e.getMessage());
log.error("Не удалось скачать логотип {}: {}", logoFilename, e.getMessage());
}
}
@@ -234,81 +252,123 @@ public class MarketingStrategyV3Service {
try {
if (contentType.contains("видео") || contentType.contains("reels") || contentType.contains("tiktok")) {
String videoPrompt = item.getVideoGenerationPrompt();
if (videoPrompt != null && !videoPrompt.isEmpty()) {
byte[] videoBytes = geminiVideoService.generateVideo(videoPrompt);
if (videoBytes != null && videoBytes.length > 0) {
String filename = "video_" + System.currentTimeMillis() + "_" + item.hashCode() + ".mp4";
minIOService.uploadFile(filename, videoBytes, "video/mp4");
item.setVideoUrl(filename);
item.setVideoFilename(filename);
}
String videoPrompt = "Кинематографичное видео. Бизнес: " + businessContext + ". Сюжет: " + item.getTheme();
byte[] videoBytes = geminiVideoService.generateVideo(videoPrompt);
if (videoBytes != null && videoBytes.length > 0) {
String filename = "video_" + System.currentTimeMillis() + "_" + item.hashCode() + ".mp4";
minIOService.uploadFile(filename, videoBytes, "video/mp4");
item.setVideoUrl(filename);
item.setVideoFilename(filename);
}
} else {
String imagePrompt = item.getImageFilename();
if (imagePrompt != null && !imagePrompt.isEmpty()) {
// ИСПОЛЬЗУЕМ СТАРЫЙ ДОБРЫЙ BUILDER ПРОМПТОВ!
String imagePrompt = buildImagePrompt(item, businessContext, brandName);
log.info("Generating image with prompt: {}", imagePrompt);
// 2. ВЫЗЫВАЕМ НОВЫЙ МЕТОД С РЕФЕРЕНСОМ ЛОГОТИПА
byte[] imageBytes = imageGenerationService.generateImageWithReference(imagePrompt, clientLogoBytes);
byte[] imageBytes = imageGenerationService.generateImageWithReference(imagePrompt, clientLogoBytes);
if (imageBytes != null && imageBytes.length > 0) {
String filename = "image_" + System.currentTimeMillis() + "_" + item.hashCode() + ".png";
minIOService.uploadFile(filename, imageBytes, MediaType.IMAGE_PNG.toString());
item.setImageUrl(filename);
item.setImageFilename(filename);
}
if (imageBytes != null && imageBytes.length > 0) {
String filename = "image_" + System.currentTimeMillis() + "_" + item.hashCode() + ".png";
minIOService.uploadFile(filename, imageBytes, MediaType.IMAGE_PNG.toString());
item.setImageUrl(filename);
item.setImageFilename(filename);
}
}
if (delayBetweenRequestsMs > 0) Thread.sleep(delayBetweenRequestsMs);
} catch (Exception e) {
log.error("Ошибка создания медиа для поста '{}': {}", item.getTheme(), e.getMessage());
}
}
}
private String getBusinessContext(MarketingAnalysisV3Document analysis) {
if (analysis == null || analysis.getRequestData() == null) return "";
return "Ниша: " + analysis.getRequestData().getBusinessNiche() + ", Продукт: " + analysis.getRequestData().getProductName();
}
private String buildImagePrompt(MarketingStrategy.PostCalendarItem item, String businessContext, String brandName) {
StringBuilder prompt = new StringBuilder();
String style = "высококачественная фотография, реалистичное освещение, 8k, высокая детализация";
if (item.getPlatform() != null) {
String platform = item.getPlatform().toLowerCase();
if (platform.contains("linkedin")) {
style += ", корпоративный стиль, профессионально, минимализм, чисто";
} else if (platform.contains("instagram") || platform.contains("tiktok")) {
style += ", лайфстайл, эстетика, яркие цвета, привлекательно, инстаграм-стиль";
} else if (platform.contains("facebook")) {
style += ", уютно, дружелюбно, для сообщества";
}
}
prompt.append("Фотореалистичное изображение. ");
if (businessContext != null && !businessContext.isEmpty()) {
prompt.append("Контекст: ").append(businessContext).append(". ");
}
if (brandName != null && !brandName.isEmpty()) {
prompt.append("Органично и нативно интегрируй название бренда '").append(brandName).append("' в окружение (например, на вывеске, упаковке, кружке или блокноте). ");
}
if (item.getTheme() != null && !item.getTheme().isEmpty()) {
prompt.append("Сюжет изображения: ").append(item.getTheme()).append(". ");
}
prompt.append("Композиция: продукт или услуга в центре внимания. ");
prompt.append("Если на фото есть люди, они должны взаимодействовать с продуктом естественно. ");
prompt.append("Избегать: абстрактных концепций, искусственных поз, размытия, искажений. ");
prompt.append("Визуальный стиль: ").append(style).append(".");
return prompt.toString();
}
// ==========================================
// ПРОМПТЫ ДЛЯ ИИ (ТЕПЕРЬ БЕЗ ПРОМПТОВ ДЛЯ КАРТИНОК В JSON)
// ==========================================
private String buildSystemPrompt(StrategyModel bestModel) {
return """
РОЛЬ: Ты — топовый Голливудский Креативный Директор и SMM Стратег.
РОЛЬ: Ты — топовый Голливудский Креативный Директор и Chief Media Planner.
Бэкенд-система произвела расчет и выбрала доминирующую стратегию: %s.
Твоя задача — сгенерировать глубокий, продуманный контент-план, строго соблюдая правила этой модели:
Твоя задача — проанализировать нишу и САМОСТОЯТЕЛЬНО:
1. Выбрать наиболее эффективные платформы (от 1 до 3 платформ из: Instagram, Facebook, LinkedIn, Telegram, TikTok, YouTube).
2. Определить оптимальную длительность первой итерации стратегии (от 2 до 8 недель).
3. Сгенерировать контент-план на основе твоих решений.
ПРАВИЛА МОДЕЛИ:
%s
КРИТИЧЕСКИЕ ПРАВИЛА ГЕНЕРАЦИИ МЕДИА-ПРОМПТОВ:
1. Если contentType = 'фото' или 'баннер', ОБЯЗАТЕЛЬНО добавь поле 'imageGenerationPrompt' (на английском языке).
Опиши сцену (высшее качество, 8k, фотореализм).
ИНТЕГРАЦИЯ БРЕНДА: Нативно впиши название бренда или продукта в физический мир картинки!
ИИ должен сам решить, куда органично вписать логотип (на кружку, футболку, ноутбук, билборд).
2. Если contentType = 'видео' или 'reels', ОБЯЗАТЕЛЬНО добавь поле 'videoGenerationPrompt' (на английском языке).
Опиши режиссерский сценарий для нейросети генерации видео (движение камеры, свет, объекты).
ВЫВОДИ ТОЛЬКО ЧИСТЫЙ JSON ПОСЛЕ БЛОКА <scratchpad>!
ВЫВОДИ ТОЛЬКО ЧИСТЫЙ JSON! Никакого лишнего текста.
""".formatted(bestModel.getTitle(), bestModel.getContentRules());
}
private String buildUserPrompt(MarketingAnalysisV3Document analysis, MarketingStrategy strategy, StrategyModel bestModel) throws Exception {
private String buildUserPrompt(MarketingAnalysisV3Document analysis, StrategyModel bestModel) throws Exception {
String clientDtoJson = objectMapper.writeValueAsString(analysis.getRequestData());
String platforms = String.join(", ", strategy.getPriorityPlatforms());
LocalDateTime startDate = LocalDateTime.now().plusDays(1);
String brandName = analysis.getRequestData().getProductName();
// УБРАЛИ imageGenerationPrompt ИЗ ШАБЛОНА, ЧТОБЫ ИИ НЕ ЛОМАЛ JSON!
return """
ДАННЫЕ БИЗНЕСА:
%s
Настройки: %d недель. Платформы: %s. Старт: %s
Бренд клиента: "%s" (Обязательно используй это имя для интеграции в 'imageGenerationPrompt'!).
Старт публикаций: %s
ВЫБРАННАЯ МОДЕЛЬ: %s
ВЫВЕДИ ТОЛЬКО JSON:
ВЫВЕДИ ТОЛЬКО JSON СТРОГО ПО ЭТОЙ СТРУКТУРЕ:
{
"selectedModel": "%s",
"rationale": "Детальное бизнес-обоснование",
"rationale": "Обоснование выбора платформ",
"recommendedDurationWeeks": 4,
"recommendedPlatforms": ["Instagram"],
"weeklyPlans": [
{
"weekNumber": 1,
"mainThemes": ["Тема 1", "Тема 2"],
"mainThemes": ["Тема 1"],
"contentRecommendations": "Что снимать и писать",
"priorityPlatforms": ["Instagram"]
}
@@ -319,31 +379,36 @@ public class MarketingStrategyV3Service {
"platform": "Instagram",
"contentType": "фото",
"theme": "Тема поста",
"postText": "Глубокий текст поста, готовый к публикации...",
"postText": "Текст поста...",
"hashtags": ["#тег"],
"publishTime": "10:00",
"imageGenerationPrompt": "Подробный промпт на английском. Пример: A high-end lifestyle shot of a workspace. The brand name '%s' is subtly engraved on the premium leather notebook lying on the table. Photorealistic, 8k.",
"videoGenerationPrompt": null
"publishTime": "10:00"
}
]
}
""".formatted(clientDtoJson, strategy.getDurationWeeks(), platforms, startDate.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME), brandName, bestModel.name(), bestModel.name(), brandName);
""".formatted(
clientDtoJson,
startDate.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME),
bestModel.name(),
bestModel.name()
);
}
private String extractCleanJson(String rawResponse) {
if (rawResponse == null || rawResponse.trim().isEmpty()) return null;
String cleaned = rawResponse.trim();
int scratchpadEnd = cleaned.indexOf("</scratchpad>");
if (scratchpadEnd != -1) cleaned = cleaned.substring(scratchpadEnd + "</scratchpad>".length()).trim();
int firstBrace = cleaned.indexOf("{");
int lastBrace = cleaned.lastIndexOf("}");
if (firstBrace != -1 && lastBrace != -1 && firstBrace <= lastBrace) return cleaned.substring(firstBrace, lastBrace + 1);
return cleaned;
}
// ==========================================
// ВСПОМОГАТЕЛЬНЫЕ МЕТОДЫ (ИЗ V1)
// ==========================================
private String extractBusinessContext(MarketingAnalysisV3Document analysis) {
if (analysis == null || analysis.getRequestData() == null) return "";
return "Ниша: " + analysis.getRequestData().getBusinessNiche() + ", Продукт: " + analysis.getRequestData().getProductName();
private String extractCleanJson(String response) {
if (response == null || response.trim().isEmpty()) return null;
String cleaned = response.trim();
// Надежное извлечение как в старом коде
int startIdx = cleaned.indexOf("{");
int endIdx = cleaned.lastIndexOf("}");
if (startIdx >= 0 && endIdx > startIdx) {
return cleaned.substring(startIdx, endIdx + 1);
}
return null;
}
private void addStatusHistoryEntry(MarketingStrategy strategy, String status, String message) {