This commit is contained in:
root
2025-12-21 10:31:30 +05:00
parent c4484cfafd
commit efcfa1dc8f
12 changed files with 370 additions and 47 deletions
@@ -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<String, Object> 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<String, Object> 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): перечисли 58 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");
@@ -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<String, Object> buildChatCompletionsRequestBody(
String model,
List<Map<String, Object>> messages,
int maxTokens,
double temperature) {
Map<String, Object> 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<Map<String, Object>> buildChatMessages(String userPrompt, String systemPrompt) {
List<Map<String, Object>> messages = new ArrayList<>();
if (systemPrompt != null && !systemPrompt.isBlank()) {
Map<String, Object> systemMessage = new HashMap<>();
systemMessage.put("role", "system");
systemMessage.put("content", systemPrompt);
messages.add(systemMessage);
}
Map<String, Object> 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<String, Object> requestBody = new HashMap<>();
requestBody.put("model", modelName);
requestBody.put("messages", List.of(message));
requestBody.put("max_tokens", 1000);
requestBody.put("temperature", 0.3);
Map<String, Object> 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<Map<String, Object>> choices = (List<Map<String, Object>>) response.get("choices");
if (choices != null && !choices.isEmpty()) {
Map<String, Object> firstChoice = choices.get(0);
Map<String, Object> messageObj = (Map<String, Object>) firstChoice.get("message");
if (messageObj != null) {
Object msg = firstChoice.get("message");
if (msg instanceof Map) {
@SuppressWarnings("unchecked")
Map<String, Object> messageObj = (Map<String, Object>) 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<String, Object> message = new HashMap<>();
message.put("role", "user");
message.put("content", prompt);
List<Map<String, Object>> messages = buildChatMessages(prompt, systemPrompt);
Map<String, Object> 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<String, Object> requestBody = buildChatCompletionsRequestBody(
modelName,
messages,
maxTokens,
DEFAULT_TEMPERATURE);
try {
// Acquire semaphore permit before making request
@@ -310,12 +371,12 @@ public class OpenAIAnalyticsService {
List<Map<String, Object>> choices = (List<Map<String, Object>>) response.get("choices");
if (choices != null && !choices.isEmpty()) {
Map<String, Object> firstChoice = choices.get(0);
Map<String, Object> messageObj = (Map<String, Object>) firstChoice.get("message");
if (messageObj != null) {
Object msg = firstChoice.get("message");
if (msg instanceof Map) {
@SuppressWarnings("unchecked")
Map<String, Object> messageObj = (Map<String, Object>) 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;