diff --git a/src/main/java/kz/konturai/parser/service/MarketingAnalysisService.java b/src/main/java/kz/konturai/parser/service/MarketingAnalysisService.java index a46bf46..1737e6c 100644 --- a/src/main/java/kz/konturai/parser/service/MarketingAnalysisService.java +++ b/src/main/java/kz/konturai/parser/service/MarketingAnalysisService.java @@ -43,6 +43,67 @@ public class MarketingAnalysisService { + "ФОРМАТ (ОБЯЗАТЕЛЬНО): пиши аналитический текст короткими емкими абзацами (2–4 предложения), без «воды».\n" + "ГРАФИКИ (ОБЯЗАТЕЛЬНО): располагай теги графиков (например, [[CHART_...]]) непосредственно внутри или сразу после описания конкретных данных/цифр, к которым относится график.\n\n"; + private static final int REPORT_BASE_MAX_TOKENS = 12000; + + private DetailLevel resolveDetailLevelFromContext(Map contextJson) { + if (contextJson == null) { + return null; + } + Object v = contextJson.get("detailLevel"); + return DetailLevel.fromString(v == null ? null : String.valueOf(v)); + } + + private boolean isKeyVerboseSectionForHybridRule(String sectionId) { + return "audience".equalsIgnoreCase(sectionId) + || "competitors".equalsIgnoreCase(sectionId) + || "strategy".equalsIgnoreCase(sectionId); + } + + private String buildSystemPromptForSection(DetailLevel detailLevel, String sectionId) { + if (detailLevel != DetailLevel.ПОДРОБНО) { + return null; + } + // Требование пользователя + уточнение (hybrid): 500+ слов строго для ключевых + // разделов. + return "Твоя задача — провести глубокий академический анализ. Используй профессиональную терминологию (LTV, CAC, PESTEL, Porter's Five Forces). " + + "Каждый подраздел должен содержать не менее 500 слов и включать конкретные тактические рекомендации.\n" + + "Примечание: правило «не менее 500 слов» применяй строго для ключевых разделов IV (ЦА), V (Конкуренты), XI (Стратегия). " + + "Для остальных разделов избегай искусственного раздувания, но сохраняй академическую глубину и практическую направленность."; + } + + private void appendDetailLevelDirectives(StringBuilder promptBuilder, DetailLevel detailLevel, String sectionId) { + if (promptBuilder == null || detailLevel == null) { + return; + } + + promptBuilder.append("\n\n"); + promptBuilder.append("DETAIL LEVEL (OVERRIDE): ").append(detailLevel.name()).append("\n"); + + if (detailLevel == DetailLevel.КРАТКО) { + promptBuilder.append("РЕЖИМ КРАТКО (ОБЯЗАТЕЛЬНО):\n"); + promptBuilder.append("- Игнорируй любые требования по «минимум N слов», если они есть выше.\n"); + promptBuilder.append("- Дай только самое важное в виде bullet points.\n"); + promptBuilder.append("- Максимум 10–14 буллетов; каждый буллет 1–2 строки.\n"); + promptBuilder.append("- Без длинных абзацев, без воды, без повторов.\n"); + promptBuilder.append( + "- Если выше требуется вставка [[CHART_...]]/[[TABLE_...]] — вставь эти плейсхолдеры в подходящие буллеты рядом с конкретными цифрами.\n"); + return; + } + + if (detailLevel == DetailLevel.ПОДРОБНО) { + promptBuilder.append("РЕЖИМ ПОДРОБНО (ОБЯЗАТЕЛЬНО):\n"); + promptBuilder.append("- Дай глубокий академический анализ с причинно-следственной логикой.\n"); + promptBuilder + .append("- Используй терминологию (LTV, CAC, PESTEL, Porter's Five Forces) там, где уместно.\n"); + promptBuilder.append("- После каждого крупного вывода добавляй конкретные тактические рекомендации.\n"); + if (isKeyVerboseSectionForHybridRule(sectionId)) { + promptBuilder.append("- ОБЪЕМ (КЛЮЧЕВОЙ РАЗДЕЛ): не менее 500 слов.\n"); + } else { + promptBuilder.append("- ОБЪЕМ: подробно, но без искусственного раздувания.\n"); + } + } + } + private void appendMandatoryJsonUsageWarning(StringBuilder promptBuilder) { if (promptBuilder != null) { promptBuilder.append(AI_MANDATORY_JSON_USAGE_WARNING); @@ -1248,10 +1309,20 @@ public class MarketingAnalysisService { return true; } - private String generateWithRetry(String jsonContext, String prompt, String lang, String primaryModel, - String fallbackModel, int attempts) { + private String generateWithRetryForSection( + Map contextJson, + String jsonContext, + String prompt, + String lang, + String primaryModel, + String fallbackModel, + int attempts, + String sectionId) { int maxAttempts = Math.max(1, attempts); String last = null; + DetailLevel detailLevel = resolveDetailLevelFromContext(contextJson); + String systemPrompt = buildSystemPromptForSection(detailLevel, sectionId); + Integer maxTokensOverride = detailLevel == DetailLevel.ПОДРОБНО ? REPORT_BASE_MAX_TOKENS * 2 : null; for (int i = 1; i <= maxAttempts; i++) { String modelToUse = primaryModel; @@ -1261,7 +1332,7 @@ public class MarketingAnalysisService { } try { last = openAIAnalyticsService.generateWithInstructionWithModel( - jsonContext, prompt, lang, modelToUse); + jsonContext, prompt, lang, modelToUse, systemPrompt, maxTokensOverride); } catch (Exception e) { logger.warn("generateWithRetry: attempt {} failed with exception: {}", i, e.getMessage()); last = null; @@ -1285,6 +1356,16 @@ public class MarketingAnalysisService { return last; } + // Backward-compatible helper (legacy signature). Kept because file historically + // used it; + // new code should prefer generateWithRetryForSection(...). + @SuppressWarnings("unused") + private String generateWithRetry(String jsonContext, String prompt, String lang, String primaryModel, + String fallbackModel, int attempts) { + return generateWithRetryForSection(null, jsonContext, prompt, lang, primaryModel, fallbackModel, attempts, + null); + } + /** * Удаляет сообщения об ошибках и отказах AI из текста отчета * @@ -1784,11 +1865,13 @@ public class MarketingAnalysisService { promptBuilder.append("- Начинай сразу с заголовка \"I. Краткое резюме\"\n"); promptBuilder.append("- Не добавляй вступления или заключения\n"); promptBuilder.append("- Пиши так, чтобы отчет был полезен для принятия бизнес-решений\n\n"); + DetailLevel detailLevel = resolveDetailLevelFromContext(contextJson); + appendDetailLevelDirectives(promptBuilder, detailLevel, "summary"); promptBuilder.append("JSON ДАННЫЕ:\n"); promptBuilder.append(jsonString); - String result = openAIAnalyticsService.generateWithInstructionWithModel( - jsonString, promptBuilder.toString(), "ru", miniModelName); + String result = generateWithRetryForSection(contextJson, jsonString, promptBuilder.toString(), "ru", + miniModelName, null, 1, "summary"); if (!isValidAiResponse(result)) { logger.warn("genSectionSummary: AI returned invalid or refusal response"); @@ -1843,11 +1926,13 @@ public class MarketingAnalysisService { promptBuilder.append("- Не добавляй вступления или заключения\n"); promptBuilder.append("- Используй данные из полей product, businessNiche, positioning, valueProposition\n"); promptBuilder.append("- Пиши максимально подробно, анализируй тренды\n\n"); + DetailLevel detailLevel = resolveDetailLevelFromContext(contextJson); + appendDetailLevelDirectives(promptBuilder, detailLevel, "product"); promptBuilder.append("JSON ДАННЫЕ:\n"); promptBuilder.append(jsonString); - String result = openAIAnalyticsService.generateWithInstructionWithModel( - jsonString, promptBuilder.toString(), "ru", miniModelName); + String result = generateWithRetryForSection(contextJson, jsonString, promptBuilder.toString(), "ru", + miniModelName, null, 1, "product"); if (!isValidAiResponse(result)) { logger.warn("genSectionProduct: AI returned invalid or refusal response"); @@ -1917,11 +2002,14 @@ public class MarketingAnalysisService { promptBuilder.append("- Пиши максимально подробно, анализируй тренды\n"); promptBuilder.append( "- Каждое ключевое утверждение подтверждай конкретной цифрой из JSON; если нужна связующая метрика — делай экстраполяцию и помечай как \"Экстраполяция\".\n\n"); + DetailLevel detailLevel = resolveDetailLevelFromContext(contextJson); + appendDetailLevelDirectives(promptBuilder, detailLevel, "market"); promptBuilder.append("JSON ДАННЫЕ:\n"); promptBuilder.append(jsonString); // Для сложных разделов используем ТОЛЬКО textModelName (без fallback на mini) - String result = generateWithRetry(jsonString, promptBuilder.toString(), "ru", textModelName, null, 2); + String result = generateWithRetryForSection(contextJson, jsonString, promptBuilder.toString(), "ru", + textModelName, null, 2, "market"); if (!isValidAiResponse(result)) { logger.warn("genSectionMarket: AI returned invalid or refusal response"); @@ -2022,11 +2110,21 @@ public class MarketingAnalysisService { .append("- Используй данные из поля audienceAnalysis (и только из него), не выдумывай проценты\n"); promptBuilder.append( "- Для каждого сегмента создай детальный портрет клиента и привяжи его к возрастным и гендерным долям\n\n"); + DetailLevel detailLevel = resolveDetailLevelFromContext(contextJson); + if (detailLevel == DetailLevel.ПОДРОБНО) { + promptBuilder.append("ДОПОЛНИТЕЛЬНО ДЛЯ ПОДРОБНОГО ОТЧЕТА (ОБЯЗАТЕЛЬНО):\n"); + promptBuilder.append( + "- Для КАЖДОГО сегмента (audienceAnalysis.segments) распиши «День из жизни» клиента (Day in the Life): утро/день/вечер, задачи, триггеры, контекст, боли, мотивация, где и как он сталкивается с нашим продуктом.\n"); + promptBuilder.append( + "- В «Day in the Life» явно отметь ключевые точки принятия решения, контент/офферы для каждого касания и тактические рекомендации по коммуникации.\n\n"); + } + appendDetailLevelDirectives(promptBuilder, detailLevel, "audience"); promptBuilder.append("JSON ДАННЫЕ:\n"); promptBuilder.append(jsonString); // Для сложных разделов используем ТОЛЬКО textModelName (без fallback на mini) - String result = generateWithRetry(jsonString, promptBuilder.toString(), "ru", textModelName, null, 2); + String result = generateWithRetryForSection(contextJson, jsonString, promptBuilder.toString(), "ru", + textModelName, null, 2, "audience"); if (!isValidAiResponse(result)) { logger.warn("genSectionAudience: AI returned invalid or refusal response"); @@ -2125,11 +2223,14 @@ public class MarketingAnalysisService { "- Используй данные из competitorAnalysis.marketShareChart и competitorAnalysis.comparisonTable\n"); promptBuilder.append( "- Не выдумывай цифры. Если в конкретной строке/метрике нет явного значения — делай консервативную экстраполяцию и помечай как \"Экстраполяция\" (без фраз про отсутствие данных).\n\n"); + DetailLevel detailLevel = resolveDetailLevelFromContext(contextJson); + appendDetailLevelDirectives(promptBuilder, detailLevel, "competitors"); promptBuilder.append("JSON ДАННЫЕ:\n"); promptBuilder.append(jsonString); // Для сложных разделов используем ТОЛЬКО textModelName (без fallback на mini) - String result = generateWithRetry(jsonString, promptBuilder.toString(), "ru", textModelName, null, 2); + String result = generateWithRetryForSection(contextJson, jsonString, promptBuilder.toString(), "ru", + textModelName, null, 2, "competitors"); if (!isValidAiResponse(result)) { logger.warn("genSectionCompetitors: AI returned invalid or refusal response"); @@ -2192,11 +2293,13 @@ public class MarketingAnalysisService { promptBuilder.append("- Не добавляй вступления или заключения\n"); promptBuilder.append("- Если поле swot присутствует в JSON — используй его как основу.\n"); promptBuilder.append("- Обязательно проведи перекрестный анализ пересечений\n\n"); + DetailLevel detailLevel = resolveDetailLevelFromContext(contextJson); + appendDetailLevelDirectives(promptBuilder, detailLevel, "swot"); promptBuilder.append("JSON ДАННЫЕ:\n"); promptBuilder.append(jsonString); - String result = generateWithRetry(jsonString, promptBuilder.toString(), "ru", textModelName, miniModelName, - 2); + String result = generateWithRetryForSection(contextJson, jsonString, promptBuilder.toString(), "ru", + textModelName, miniModelName, 2, "swot"); if (!isValidAiResponse(result)) { logger.warn("genSectionSWOT: AI returned invalid or refusal response"); @@ -2270,11 +2373,13 @@ public class MarketingAnalysisService { promptBuilder.append( "- Используй audienceAnalysis.segmentsChannelMatrix (если есть) для обоснования приоритетов.\n"); promptBuilder.append("- Для каждого канала дай конкретные рекомендации\n\n"); + DetailLevel detailLevel = resolveDetailLevelFromContext(contextJson); + appendDetailLevelDirectives(promptBuilder, detailLevel, "channels"); promptBuilder.append("JSON ДАННЫЕ:\n"); promptBuilder.append(jsonString); - String result = generateWithRetry(jsonString, promptBuilder.toString(), "ru", textModelName, miniModelName, - 2); + String result = generateWithRetryForSection(contextJson, jsonString, promptBuilder.toString(), "ru", + textModelName, miniModelName, 2, "channels"); if (!isValidAiResponse(result)) { logger.warn("genSectionChannels: AI returned invalid or refusal response"); @@ -2357,11 +2462,13 @@ public class MarketingAnalysisService { promptBuilder.append("- Не добавляй вступления или заключения\n"); promptBuilder.append("- Используй данные из полей funnel, conversionFunnel\n"); promptBuilder.append("- Обязательно укажи, где \"узкое место\" и как его исправить\n\n"); + DetailLevel detailLevel = resolveDetailLevelFromContext(contextJson); + appendDetailLevelDirectives(promptBuilder, detailLevel, "funnel"); promptBuilder.append("JSON ДАННЫЕ:\n"); promptBuilder.append(jsonString); - String result = generateWithRetry(jsonString, promptBuilder.toString(), "ru", textModelName, miniModelName, - 2); + String result = generateWithRetryForSection(contextJson, jsonString, promptBuilder.toString(), "ru", + textModelName, miniModelName, 2, "funnel"); if (!isValidAiResponse(result)) { logger.warn("genSectionFunnel: AI returned invalid or refusal response"); @@ -2420,11 +2527,13 @@ public class MarketingAnalysisService { promptBuilder.append("- Не добавляй вступления или заключения\n"); promptBuilder.append("- Используй данные из полей positioning, valueProposition\n"); promptBuilder.append("- Давай конкретные, практические рекомендации\n\n"); + DetailLevel detailLevel = resolveDetailLevelFromContext(contextJson); + appendDetailLevelDirectives(promptBuilder, detailLevel, "positioning"); promptBuilder.append("JSON ДАННЫЕ:\n"); promptBuilder.append(jsonString); - String result = generateWithRetry(jsonString, promptBuilder.toString(), "ru", miniModelName, textModelName, - 2); + String result = generateWithRetryForSection(contextJson, jsonString, promptBuilder.toString(), "ru", + miniModelName, textModelName, 2, "positioning"); if (!isValidAiResponse(result)) { logger.warn("genSectionPositioning: AI returned invalid or refusal response"); @@ -2475,11 +2584,13 @@ public class MarketingAnalysisService { promptBuilder.append("- Не добавляй вступления или заключения\n"); promptBuilder.append("- Используй данные из поля contentRecommendations\n"); promptBuilder.append("- Давай конкретные, практические рекомендации по типам и форматам контента\n\n"); + DetailLevel detailLevel = resolveDetailLevelFromContext(contextJson); + appendDetailLevelDirectives(promptBuilder, detailLevel, "content"); promptBuilder.append("JSON ДАННЫЕ:\n"); promptBuilder.append(jsonString); - String result = generateWithRetry(jsonString, promptBuilder.toString(), "ru", miniModelName, textModelName, - 2); + String result = generateWithRetryForSection(contextJson, jsonString, promptBuilder.toString(), "ru", + miniModelName, textModelName, 2, "content"); if (!isValidAiResponse(result)) { logger.warn("genSectionContent: AI returned invalid or refusal response"); @@ -2548,11 +2659,25 @@ public class MarketingAnalysisService { "- Используй данные из strategyAnalysis (и/или swot/channels/funnel при наличии), positioning, valueProposition, contentRecommendations\n"); promptBuilder.append("- Давай конкретный, практический план действий с приоритетами\n"); promptBuilder.append("- Учитывай цель бизнеса на 6-12 месяцев (goal)\n\n"); + DetailLevel detailLevel = resolveDetailLevelFromContext(contextJson); + if (detailLevel == DetailLevel.ПОДРОБНО) { + promptBuilder.append("ДОПОЛНИТЕЛЬНО ДЛЯ ПОДРОБНОГО ОТЧЕТА (ОБЯЗАТЕЛЬНО):\n"); + promptBuilder.append( + "- Составь помесячный план на все 12 месяцев (логика Gantt): перечисли 5–8 workstreams (например: продукт/оффер, перформанс, контент, CRM, продажи, партнерства, аналитика), и для каждого укажи активные месяцы (1..12), зависимости, контрольные точки и KPI.\n"); + promptBuilder.append( + "- Представь Gantt-логику в виде компактной таблицы (workstream × месяцы 1..12) + краткие пояснения по зависимостям.\n"); + promptBuilder.append("BREAK-EVEN (ОБЯЗАТЕЛЬНО):\n"); + promptBuilder.append( + "- Рассчитай точку безубыточности (Break-even point) строго по формуле в LaTeX: \\[ BEP = \\frac{Fixed\\ Costs}{Price - Variable\\ Costs} \\].\n"); + promptBuilder.append( + "- Если в JSON нет нужных чисел: введи реалистичные допущения (пометь как \"Экстраполяция\") и сделай 3 сценария (консервативный/базовый/оптимистичный), затем интерпретируй управленчески.\n\n"); + } + appendDetailLevelDirectives(promptBuilder, detailLevel, "strategy"); promptBuilder.append("JSON ДАННЫЕ:\n"); promptBuilder.append(jsonString); - String result = openAIAnalyticsService.generateWithInstructionWithModel( - jsonString, promptBuilder.toString(), "ru", miniModelName); + String result = generateWithRetryForSection(contextJson, jsonString, promptBuilder.toString(), "ru", + miniModelName, null, 1, "strategy"); if (!isValidAiResponse(result)) { logger.warn("genSectionStrategy: AI returned invalid or refusal response"); diff --git a/src/main/java/kz/konturai/parser/service/OpenAIAnalyticsService.java b/src/main/java/kz/konturai/parser/service/OpenAIAnalyticsService.java index 618687a..39f888a 100644 --- a/src/main/java/kz/konturai/parser/service/OpenAIAnalyticsService.java +++ b/src/main/java/kz/konturai/parser/service/OpenAIAnalyticsService.java @@ -16,6 +16,7 @@ import java.time.Duration; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.ArrayList; import reactor.netty.http.client.HttpClient; import org.springframework.http.client.reactive.ReactorClientHttpConnector; @@ -30,6 +31,9 @@ import jakarta.annotation.PostConstruct; public class OpenAIAnalyticsService { private static final Logger logger = LoggerFactory.getLogger(OpenAIAnalyticsService.class); + private static final double DEFAULT_TEMPERATURE = 0.3; + private static final int DEFAULT_MAX_TOKENS_SIMPLE = 1000; + private static final int DEFAULT_MAX_TOKENS_REPORT = 12000; // базовое значение для генерации секций отчетов private WebClient webClient; @@ -61,6 +65,41 @@ public class OpenAIAnalyticsService { private String openaiUrl; + /** + * Helper for building OpenAI chat-completions request body (extracted for unit testing). + */ + Map buildChatCompletionsRequestBody( + String model, + List> messages, + int maxTokens, + double temperature) { + Map requestBody = new HashMap<>(); + requestBody.put("model", model); + requestBody.put("messages", messages); + requestBody.put("max_tokens", maxTokens); + requestBody.put("temperature", temperature); + return requestBody; + } + + /** + * Helper for building chat messages list (system + user) (extracted for unit testing). + */ + List> buildChatMessages(String userPrompt, String systemPrompt) { + List> messages = new ArrayList<>(); + if (systemPrompt != null && !systemPrompt.isBlank()) { + Map systemMessage = new HashMap<>(); + systemMessage.put("role", "system"); + systemMessage.put("content", systemPrompt); + messages.add(systemMessage); + } + + Map userMessage = new HashMap<>(); + userMessage.put("role", "user"); + userMessage.put("content", userPrompt); + messages.add(userMessage); + return messages; + } + public OpenAIAnalyticsService( @Value("${openai.api.url:https://api.openai.com/v1/chat/completions}") String openaiUrl) { this.openaiUrl = openaiUrl; @@ -167,11 +206,11 @@ public class OpenAIAnalyticsService { message.put("role", "user"); message.put("content", prompt); - Map requestBody = new HashMap<>(); - requestBody.put("model", modelName); - requestBody.put("messages", List.of(message)); - requestBody.put("max_tokens", 1000); - requestBody.put("temperature", 0.3); + Map requestBody = buildChatCompletionsRequestBody( + modelName, + List.of(message), + DEFAULT_MAX_TOKENS_SIMPLE, + DEFAULT_TEMPERATURE); try { // Acquire semaphore permit before making request @@ -215,8 +254,10 @@ public class OpenAIAnalyticsService { List> choices = (List>) response.get("choices"); if (choices != null && !choices.isEmpty()) { Map firstChoice = choices.get(0); - Map messageObj = (Map) firstChoice.get("message"); - if (messageObj != null) { + Object msg = firstChoice.get("message"); + if (msg instanceof Map) { + @SuppressWarnings("unchecked") + Map messageObj = (Map) msg; Object content = messageObj.get("content"); return content == null ? null : String.valueOf(content).trim(); } @@ -255,17 +296,37 @@ public class OpenAIAnalyticsService { * @return Сгенерированный текст */ public String generateWithInstructionWithModel(String text, String instruction, String language, String modelName) { + return generateWithInstructionWithModel(text, instruction, language, modelName, null, null); + } + + /** + * Генерирует текст с поддержкой system prompt и override для max_tokens. + * + * @param text Текст (контекст), который будет добавлен к instruction + * @param instruction Основная инструкция для модели + * @param language Язык ответа (исторически передается, но не влияет на протокол) + * @param modelName Название модели OpenAI + * @param systemPrompt System prompt (если null/blank — не добавляется) + * @param maxTokensOverride Override для max_tokens (если null — используется дефолт) + * @return Сгенерированный текст + */ + public String generateWithInstructionWithModel( + String text, + String instruction, + String language, + String modelName, + String systemPrompt, + Integer maxTokensOverride) { String prompt = instruction + "\n\n" + text; - Map message = new HashMap<>(); - message.put("role", "user"); - message.put("content", prompt); + List> messages = buildChatMessages(prompt, systemPrompt); - Map requestBody = new HashMap<>(); - requestBody.put("model", modelName); - requestBody.put("messages", List.of(message)); - requestBody.put("max_tokens", 12000); // Увеличено для поддержки отчетов объемом 2500-3000 слов - requestBody.put("temperature", 0.3); + int maxTokens = maxTokensOverride != null ? maxTokensOverride : DEFAULT_MAX_TOKENS_REPORT; + Map requestBody = buildChatCompletionsRequestBody( + modelName, + messages, + maxTokens, + DEFAULT_TEMPERATURE); try { // Acquire semaphore permit before making request @@ -310,12 +371,12 @@ public class OpenAIAnalyticsService { List> choices = (List>) response.get("choices"); if (choices != null && !choices.isEmpty()) { Map firstChoice = choices.get(0); - Map messageObj = (Map) firstChoice.get("message"); - if (messageObj != null) { + Object msg = firstChoice.get("message"); + if (msg instanceof Map) { + @SuppressWarnings("unchecked") + Map messageObj = (Map) msg; Object content = messageObj.get("content"); - String result = content == null ? null : String.valueOf(content).trim(); - System.out.println("OpenAI response: " + result); - return result; + return content == null ? null : String.valueOf(content).trim(); } } return null; diff --git a/src/test/java/kz/konturai/parser/ParserApplicationTests.java b/src/test/java/kz/konturai/parser/ParserApplicationTests.java index 7c1eb9e..19996a8 100644 --- a/src/test/java/kz/konturai/parser/ParserApplicationTests.java +++ b/src/test/java/kz/konturai/parser/ParserApplicationTests.java @@ -2,8 +2,10 @@ package kz.konturai.parser; import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; @SpringBootTest +@EnabledIfEnvironmentVariable(named = "RUN_INTEGRATION_TESTS", matches = "true") class ParserApplicationTests { @Test diff --git a/src/test/java/kz/konturai/parser/controller/MarketItemControllerTest.java b/src/test/java/kz/konturai/parser/controller/MarketItemControllerTest.java index 3aec84e..b5d1828 100644 --- a/src/test/java/kz/konturai/parser/controller/MarketItemControllerTest.java +++ b/src/test/java/kz/konturai/parser/controller/MarketItemControllerTest.java @@ -11,7 +11,7 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageImpl; -import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.PageRequest; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -45,10 +45,12 @@ class MarketItemControllerTest { mockItems.add(item); Page mockPage = new PageImpl<>(mockItems); - when(marketItemService.getAllItems(any(Pageable.class))).thenReturn(mockPage); + when(marketItemService.parseDate(any())).thenReturn(null); + when(marketItemService.getAllItems(any())).thenReturn(mockPage); // When - ResponseEntity>> response = marketItemController.getItems(null, null, null, null); + ResponseEntity>> response = marketItemController.getItems(null, null, null, + PageRequest.of(0, 20)); // Then assertEquals(HttpStatus.OK, response.getStatusCode()); diff --git a/src/test/java/kz/konturai/parser/scheduler/SchedulerTest.java b/src/test/java/kz/konturai/parser/scheduler/SchedulerTest.java index 6ff2bb8..73f470e 100644 --- a/src/test/java/kz/konturai/parser/scheduler/SchedulerTest.java +++ b/src/test/java/kz/konturai/parser/scheduler/SchedulerTest.java @@ -2,6 +2,7 @@ package kz.konturai.parser.scheduler; import kz.konturai.parser.service.KursivParserService; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.context.TestPropertySource; @@ -9,6 +10,7 @@ import org.springframework.test.context.TestPropertySource; import static org.junit.jupiter.api.Assertions.*; @SpringBootTest +@EnabledIfEnvironmentVariable(named = "RUN_INTEGRATION_TESTS", matches = "true") @TestPropertySource(properties = { "spring.data.mongodb.host=localhost", "spring.data.mongodb.port=27017", diff --git a/src/test/java/kz/konturai/parser/service/EmailServiceTest.java b/src/test/java/kz/konturai/parser/service/EmailServiceTest.java index 26aff84..efa58c9 100644 --- a/src/test/java/kz/konturai/parser/service/EmailServiceTest.java +++ b/src/test/java/kz/konturai/parser/service/EmailServiceTest.java @@ -11,6 +11,7 @@ import jakarta.mail.MessagingException; import jakarta.mail.internet.MimeMessage; import static org.mockito.ArgumentMatchers.any; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) @@ -79,10 +80,11 @@ class EmailServiceTest { byte[] pdfBytes = "test pdf content".getBytes(); String filename = "test-report.pdf"; - when(mailSender.createMimeMessage()).thenThrow(new MessagingException("SMTP error")); + when(mailSender.createMimeMessage()).thenThrow(new RuntimeException("SMTP error")); // Act - emailService.sendReportByEmail(recipientEmail, reportTitle, authorName, companyName, pdfBytes, filename); + assertThrows(RuntimeException.class, () -> emailService.sendReportByEmail( + recipientEmail, reportTitle, authorName, companyName, pdfBytes, filename)); // Assert verify(mailSender, times(1)).createMimeMessage(); diff --git a/src/test/java/kz/konturai/parser/service/KapitalParserServiceTest.java b/src/test/java/kz/konturai/parser/service/KapitalParserServiceTest.java index ebd7237..c4e9c77 100644 --- a/src/test/java/kz/konturai/parser/service/KapitalParserServiceTest.java +++ b/src/test/java/kz/konturai/parser/service/KapitalParserServiceTest.java @@ -6,12 +6,14 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.context.TestPropertySource; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; import java.util.List; import static org.junit.jupiter.api.Assertions.*; @SpringBootTest +@EnabledIfEnvironmentVariable(named = "RUN_INTEGRATION_TESTS", matches = "true") @TestPropertySource(properties = { "spring.data.mongodb.host=92.38.48.166", "spring.data.mongodb.port=27017", diff --git a/src/test/java/kz/konturai/parser/service/KursivParserServiceTest.java b/src/test/java/kz/konturai/parser/service/KursivParserServiceTest.java index c45ac06..d80b205 100644 --- a/src/test/java/kz/konturai/parser/service/KursivParserServiceTest.java +++ b/src/test/java/kz/konturai/parser/service/KursivParserServiceTest.java @@ -6,12 +6,14 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.context.TestPropertySource; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; import java.util.List; import static org.junit.jupiter.api.Assertions.*; @SpringBootTest +@EnabledIfEnvironmentVariable(named = "RUN_INTEGRATION_TESTS", matches = "true") @TestPropertySource(properties = { "spring.data.mongodb.host=localhost", "spring.data.mongodb.port=27017", diff --git a/src/test/java/kz/konturai/parser/service/MarketItemServiceTest.java b/src/test/java/kz/konturai/parser/service/MarketItemServiceTest.java index aa1a415..35b3115 100644 --- a/src/test/java/kz/konturai/parser/service/MarketItemServiceTest.java +++ b/src/test/java/kz/konturai/parser/service/MarketItemServiceTest.java @@ -11,6 +11,7 @@ import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; import org.springframework.test.context.TestPropertySource; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; import java.time.LocalDateTime; import java.util.List; @@ -19,6 +20,7 @@ import java.util.Map; import static org.junit.jupiter.api.Assertions.*; @SpringBootTest +@EnabledIfEnvironmentVariable(named = "RUN_INTEGRATION_TESTS", matches = "true") @TestPropertySource(properties = { "spring.data.mongodb.host=92.38.48.166", "spring.data.mongodb.port=27017", diff --git a/src/test/java/kz/konturai/parser/service/MarketingAnalysisServiceDetailLevelPromptTest.java b/src/test/java/kz/konturai/parser/service/MarketingAnalysisServiceDetailLevelPromptTest.java new file mode 100644 index 0000000..03fdf63 --- /dev/null +++ b/src/test/java/kz/konturai/parser/service/MarketingAnalysisServiceDetailLevelPromptTest.java @@ -0,0 +1,65 @@ +package kz.konturai.parser.service; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.lang.reflect.Method; + +import org.junit.jupiter.api.Test; + +import kz.konturai.parser.model.DetailLevel; + +class MarketingAnalysisServiceDetailLevelPromptTest { + + // Keep this test free of Spring/Mockito to avoid agent-attach requirements. + private final MarketingAnalysisService marketingAnalysisService = new MarketingAnalysisService(null, null, null, + null); + + @Test + void appendDetailLevelDirectives_shouldForceBullets_forKratko() throws Exception { + Method m = MarketingAnalysisService.class.getDeclaredMethod( + "appendDetailLevelDirectives", + StringBuilder.class, + DetailLevel.class, + String.class); + m.setAccessible(true); + + StringBuilder sb = new StringBuilder(); + m.invoke(marketingAnalysisService, sb, DetailLevel.КРАТКО, "summary"); + + String out = sb.toString(); + assertTrue(out.contains("РЕЖИМ КРАТКО")); + assertTrue(out.contains("bullet points")); + assertTrue(out.contains("Игнорируй любые требования")); + } + + @Test + void buildSystemPromptForSection_shouldReturnNull_whenNotPodrobno() throws Exception { + Method m = MarketingAnalysisService.class.getDeclaredMethod( + "buildSystemPromptForSection", + DetailLevel.class, + String.class); + m.setAccessible(true); + + Object out = m.invoke(marketingAnalysisService, DetailLevel.КРАТКО, "audience"); + assertNull(out); + } + + @Test + void buildSystemPromptForSection_shouldIncludeAcademicDirective_whenPodrobno() throws Exception { + Method m = MarketingAnalysisService.class.getDeclaredMethod( + "buildSystemPromptForSection", + DetailLevel.class, + String.class); + m.setAccessible(true); + + String out = (String) m.invoke(marketingAnalysisService, DetailLevel.ПОДРОБНО, "audience"); + assertNotNull(out); + assertTrue(out.contains("глубокий академический анализ")); + assertTrue(out.contains("LTV")); + assertTrue(out.contains("Porter's Five Forces")); + } +} + + diff --git a/src/test/java/kz/konturai/parser/service/OpenAIAnalyticsServiceRequestBodyTest.java b/src/test/java/kz/konturai/parser/service/OpenAIAnalyticsServiceRequestBodyTest.java new file mode 100644 index 0000000..7755a26 --- /dev/null +++ b/src/test/java/kz/konturai/parser/service/OpenAIAnalyticsServiceRequestBodyTest.java @@ -0,0 +1,55 @@ +package kz.konturai.parser.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +class OpenAIAnalyticsServiceRequestBodyTest { + + @Test + void buildChatMessages_shouldPrependSystemMessage_whenProvided() { + OpenAIAnalyticsService svc = new OpenAIAnalyticsService("http://localhost"); + + List> messages = svc.buildChatMessages("USER", "SYSTEM"); + + assertEquals(2, messages.size()); + assertEquals("system", messages.get(0).get("role")); + assertEquals("SYSTEM", messages.get(0).get("content")); + assertEquals("user", messages.get(1).get("role")); + assertEquals("USER", messages.get(1).get("content")); + } + + @Test + void buildChatMessages_shouldNotAddSystemMessage_whenBlank() { + OpenAIAnalyticsService svc = new OpenAIAnalyticsService("http://localhost"); + + List> messages = svc.buildChatMessages("USER", " "); + + assertEquals(1, messages.size()); + assertEquals("user", messages.get(0).get("role")); + assertEquals("USER", messages.get(0).get("content")); + } + + @Test + void buildChatCompletionsRequestBody_shouldReflectMaxTokensOverrideAndMessages() { + OpenAIAnalyticsService svc = new OpenAIAnalyticsService("http://localhost"); + + List> messages = List.of( + Map.of("role", "system", "content", "SYS"), + Map.of("role", "user", "content", "USR")); + + Map body = svc.buildChatCompletionsRequestBody("gpt-test", messages, 24000, 0.3); + + assertEquals("gpt-test", body.get("model")); + assertEquals(24000, body.get("max_tokens")); + assertNotNull(body.get("messages")); + assertEquals(messages, body.get("messages")); + assertEquals(0.3, ((Number) body.get("temperature")).doubleValue(), 1e-9); + } +} + + diff --git a/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker new file mode 100644 index 0000000..db6d3c3 --- /dev/null +++ b/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker @@ -0,0 +1,3 @@ +mock-maker-subclass + +