diff --git a/src/main/java/kz/konturai/parser/controller/MarketingAnalysisV3Controller.java b/src/main/java/kz/konturai/parser/controller/MarketingAnalysisV3Controller.java index 4e92ae8..463dba5 100644 --- a/src/main/java/kz/konturai/parser/controller/MarketingAnalysisV3Controller.java +++ b/src/main/java/kz/konturai/parser/controller/MarketingAnalysisV3Controller.java @@ -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 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 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 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 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, diff --git a/src/main/java/kz/konturai/parser/service/MarketingStrategyV3Service.java b/src/main/java/kz/konturai/parser/service/MarketingStrategyV3Service.java index 6338e9d..7d57172 100644 --- a/src/main/java/kz/konturai/parser/service/MarketingStrategyV3Service.java +++ b/src/main/java/kz/konturai/parser/service/MarketingStrategyV3Service.java @@ -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 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 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 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 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 strategyData) { strategy.setStrategyData(strategyData); + if (strategyData.containsKey("recommendedDurationWeeks")) { + strategy.setDurationWeeks((Integer) strategyData.get("recommendedDurationWeeks")); + } + if (strategyData.containsKey("recommendedPlatforms")) { + strategy.setPriorityPlatforms((List) strategyData.get("recommendedPlatforms")); + } + List> weeksMap = (List>) strategyData.get("weeklyPlans"); List weeklyPlans = new ArrayList<>(); if (weeksMap != null) { @@ -184,14 +195,24 @@ public class MarketingStrategyV3Service { List> calendarMap = (List>) strategyData.get("postCalendar"); List postCalendar = new ArrayList<>(); + LocalDateTime startDate = LocalDateTime.now().plusDays(1); + if (calendarMap != null) { for (Map 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) 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 ПОСЛЕ БЛОКА ! + ВЫВОДИ ТОЛЬКО ЧИСТЫЙ 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(""); - if (scratchpadEnd != -1) cleaned = cleaned.substring(scratchpadEnd + "".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) {