diff --git a/OPENAI_INTEGRATION.md b/OPENAI_INTEGRATION.md new file mode 100644 index 0000000..92ace39 --- /dev/null +++ b/OPENAI_INTEGRATION.md @@ -0,0 +1,112 @@ +# Интеграция OpenAI API + +## Обзор + +Создан новый сервис `OpenAIAnalyticsService` для замены `OllamaAnalyticsService`. Новый сервис использует OpenAI API для анализа текста и генерации контента. + +## Основные изменения + +### 1. Новый сервис OpenAIAnalyticsService + +- **Файл**: `src/main/java/kz/konturai/parser/service/OpenAIAnalyticsService.java` +- **Функциональность**: Аналогична `OllamaAnalyticsService`, но использует OpenAI API +- **Методы**: + - `analyzeText(String text)` - анализ текста с извлечением саммари, тегов, тональности и сущностей + - `analyzeText(String text, String language)` - анализ с указанием языка + - `generateWithInstruction(String text, String instruction)` - генерация текста по инструкции + - `generateWithInstruction(String text, String instruction, String language)` - генерация с указанием языка + +### 2. Обновленные сервисы + +Все сервисы, которые использовали `OllamaAnalyticsService`, теперь используют `OpenAIAnalyticsService`: + +- `ReportSynthesisService` +- `ReportGenerationService` +- `KursivParserService` +- `KapitalParserService` +- `LsmParserService` +- `RbcParserService` +- `VedomostiParserService` + +### 3. Конфигурация + +В `application.properties` уже настроены параметры для OpenAI: + +```properties +# OpenAI Configuration +openai.api.key=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 +openai.api.url=https://api.openai.com/v1/chat/completions +openai.model.name=gpt-4o-mini +openai.timeoutMs=90000 +``` + +### 4. Тестовый контроллер + +Создан `OpenAITestController` для тестирования функциональности: + +- `POST /api/openai/analyze` - анализ текста +- `POST /api/openai/generate` - генерация по инструкции +- `GET /api/openai/test` - тест подключения + +## Использование + +### Анализ текста + +```java +@Autowired +private OpenAIAnalyticsService openAIAnalyticsService; + +// Анализ текста на русском языке +MarketItem.Analytics analytics = openAIAnalyticsService.analyzeText(text, "ru"); + +// Получение саммари +String summary = analytics.getSummary(); + +// Получение тегов +String[] tags = analytics.getTags(); + +// Получение тональности +String sentiment = analytics.getSentiment(); + +// Получение сущностей +Map entities = analytics.getEntities(); +``` + +### Генерация текста + +```java +// Генерация отчета +String instruction = "Напиши краткий отчет на основе следующих данных:"; +String generatedText = openAIAnalyticsService.generateWithInstruction(data, instruction, "ru"); +``` + +## Преимущества OpenAI API + +1. **Высокое качество**: GPT-4o-mini обеспечивает более качественный анализ и генерацию текста +2. **Надежность**: Стабильная работа API без необходимости локального сервера +3. **Масштабируемость**: Легко масштабируется под нагрузку +4. **Многоязычность**: Отличная поддержка русского и английского языков +5. **Консистентность**: Более предсказуемые результаты + +## Миграция + +Все существующие вызовы `OllamaAnalyticsService` автоматически заменены на `OpenAIAnalyticsService`. API остается совместимым, поэтому дополнительных изменений в коде не требуется. + +## Тестирование + +Для тестирования нового сервиса: + +1. Запустите приложение +2. Откройте `GET /api/openai/test` для проверки подключения +3. Используйте `POST /api/openai/analyze` для анализа текста +4. Используйте `POST /api/openai/generate` для генерации контента + +## Настройка + +Убедитесь, что в `application.properties` указан корректный API ключ OpenAI: + +```properties +openai.api.key=your-openai-api-key-here +``` + +Модель по умолчанию: `gpt-4o-mini` (можно изменить в настройках). diff --git a/src/main/java/kz/konturai/parser/controller/OpenAITestController.java b/src/main/java/kz/konturai/parser/controller/OpenAITestController.java new file mode 100644 index 0000000..d8a13be --- /dev/null +++ b/src/main/java/kz/konturai/parser/controller/OpenAITestController.java @@ -0,0 +1,42 @@ +package kz.konturai.parser.controller; + +import kz.konturai.parser.model.MarketItem; +import kz.konturai.parser.service.OpenAIAnalyticsService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/openai") +public class OpenAITestController { + + @Autowired + private OpenAIAnalyticsService openAIAnalyticsService; + + @PostMapping("/analyze") + public MarketItem.Analytics analyzeText(@RequestParam String text, + @RequestParam(defaultValue = "ru") String language) { + return openAIAnalyticsService.analyzeText(text, language); + } + + @PostMapping("/generate") + public String generateWithInstruction(@RequestParam String text, + @RequestParam String instruction, + @RequestParam(defaultValue = "ru") String language) { + return openAIAnalyticsService.generateWithInstruction(text, instruction, language); + } + + @GetMapping("/test") + public String testConnection() { + String testText = "Это тестовая новость о развитии технологий в Казахстане. Компания Kaspi Bank объявила о новых инвестициях в цифровые сервисы."; + MarketItem.Analytics analytics = openAIAnalyticsService.analyzeText(testText); + + StringBuilder result = new StringBuilder(); + result.append("OpenAI Analytics Service Test:\n"); + result.append("Summary: ").append(analytics.getSummary()).append("\n"); + result.append("Sentiment: ").append(analytics.getSentiment()).append("\n"); + result.append("Tags: ").append(String.join(", ", analytics.getTags())).append("\n"); + result.append("Entities: ").append(analytics.getEntities()).append("\n"); + + return result.toString(); + } +} diff --git a/src/main/java/kz/konturai/parser/service/KapitalParserService.java b/src/main/java/kz/konturai/parser/service/KapitalParserService.java index 1124396..d7b17e9 100644 --- a/src/main/java/kz/konturai/parser/service/KapitalParserService.java +++ b/src/main/java/kz/konturai/parser/service/KapitalParserService.java @@ -33,7 +33,7 @@ public class KapitalParserService implements ParserService { private MarketItemRepository marketItemRepository; @Autowired - private OllamaAnalyticsService analyticsService; + private OpenAIAnalyticsService analyticsService; @Value("${rss.kapital.url}") private String rssFeedUrl; diff --git a/src/main/java/kz/konturai/parser/service/KursivParserService.java b/src/main/java/kz/konturai/parser/service/KursivParserService.java index b6812f4..9edb46f 100644 --- a/src/main/java/kz/konturai/parser/service/KursivParserService.java +++ b/src/main/java/kz/konturai/parser/service/KursivParserService.java @@ -33,7 +33,7 @@ public class KursivParserService implements ParserService { private MarketItemRepository marketItemRepository; @Autowired - private OllamaAnalyticsService analyticsService; + private OpenAIAnalyticsService analyticsService; @Value("${rss.feed.url}") private String rssFeedUrl; diff --git a/src/main/java/kz/konturai/parser/service/LsmParserService.java b/src/main/java/kz/konturai/parser/service/LsmParserService.java index ecb000a..c3c9e2d 100644 --- a/src/main/java/kz/konturai/parser/service/LsmParserService.java +++ b/src/main/java/kz/konturai/parser/service/LsmParserService.java @@ -33,7 +33,7 @@ public class LsmParserService implements ParserService { private MarketItemRepository marketItemRepository; @Autowired - private OllamaAnalyticsService analyticsService; + private OpenAIAnalyticsService analyticsService; @Value("${rss.lsm.url}") private String rssFeedUrl; diff --git a/src/main/java/kz/konturai/parser/service/OpenAIAnalyticsService.java b/src/main/java/kz/konturai/parser/service/OpenAIAnalyticsService.java new file mode 100644 index 0000000..a650ceb --- /dev/null +++ b/src/main/java/kz/konturai/parser/service/OpenAIAnalyticsService.java @@ -0,0 +1,217 @@ +package kz.konturai.parser.service; + +import kz.konturai.parser.model.MarketItem; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Service; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.core.type.TypeReference; +import org.springframework.core.ParameterizedTypeReference; + +import java.time.Duration; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import reactor.netty.http.client.HttpClient; +import org.springframework.http.client.reactive.ReactorClientHttpConnector; + +@Service +public class OpenAIAnalyticsService { + + private static final Logger logger = LoggerFactory.getLogger(OpenAIAnalyticsService.class); + + private final WebClient webClient; + + @Value("${openai.model.name:gpt-4o-mini}") + private String modelName; + + @Value("${openai.timeoutMs:90000}") + private long timeoutMs; + + @Value("${openai.api.key}") + private String apiKey; + + public OpenAIAnalyticsService(@Value("${openai.api.url:https://api.openai.com/v1/chat/completions}") String openaiUrl) { + HttpClient httpClient = HttpClient.create() + .compress(true) + .responseTimeout(Duration.ofMillis(timeoutMs)); + + this.webClient = WebClient.builder() + .baseUrl(openaiUrl) + .clientConnector(new ReactorClientHttpConnector(httpClient)) + .defaultHeader("Authorization", "Bearer " + apiKey) + .build(); + } + + public MarketItem.Analytics analyzeText(String text) { + return analyzeText(text, "ru"); + } + + public MarketItem.Analytics analyzeText(String text, String language) { + MarketItem.Analytics analytics = new MarketItem.Analytics(); + boolean isRussian = "ru".equalsIgnoreCase(language) || "russian".equalsIgnoreCase(language); + + try { + String summaryPrompt = isRussian + ? "Напиши краткое саммари следующей новостной статьи на русском языке. Ответ должен содержать только саммари из 3-4 предложений, без лишних вступлений. Статья: " + : "Write a brief summary of the following news article in English. The response should contain only a summary of 3-4 sentences, without any unnecessary introductions. Article: "; + String summary = generate(text, summaryPrompt); + analytics.setSummary(summary); + } catch (Exception e) { + logger.warn("Failed to get summary from OpenAI: {}", e.getMessage()); + } + + try { + String tagsPrompt = isRussian + ? "Извлеки 5-7 ключевых слов или тегов из текста новостной статьи. В ответе дай только список тегов через запятую, без нумерации и заголовков. Статья: " + : "Extract 5-7 key words or tags from the news article text. In your response, provide only a list of tags separated by commas, without numbering or headers. Article: "; + String tagsCsv = generate(text, tagsPrompt); + String[] tags = tagsCsv == null ? new String[] {} : tagsCsv.replace("\n", " ").split("\\s*,\\s*"); + analytics.setTags(tags); + } catch (Exception e) { + logger.warn("Failed to get tags from OpenAI: {}", e.getMessage()); + } + + try { + String sentimentPrompt = isRussian + ? "Определи тональность текста новостной статьи. В ответе дай только одно слово латиницей: positive, negative или neutral. Статья: " + : "Determine the sentiment of the news article text. In your response, provide only one word in English: positive, negative, or neutral. Article: "; + String sentiment = generate(text, sentimentPrompt); + if (sentiment != null) { + sentiment = sentiment.trim().toLowerCase(); + } + analytics.setSentiment(sentiment); + } catch (Exception e) { + logger.warn("Failed to get sentiment from OpenAI: {}", e.getMessage()); + } + + try { + String entitiesPrompt = isRussian + ? "Извлеки из текста имена людей, названия компаний и географические локации. В ответе дай только JSON объект следующей структуры: {\"persons\": [], \"companies\": [], \"locations\": []}. Статья: " + : "Extract from the text names of people, company names, and geographic locations. In your response, provide only a JSON object with the following structure: {\"persons\": [], \"companies\": [], \"locations\": []}. Article: "; + String entitiesJson = generate(text, entitiesPrompt); + Map entities = new HashMap<>(); + if (entitiesJson != null && entitiesJson.trim().startsWith("{")) { + try { + ObjectMapper mapper = new ObjectMapper(); + entities = mapper.readValue(entitiesJson, new TypeReference>() { + }); + } catch (Exception parseEx) { + entities.put("raw", entitiesJson); + } + } + analytics.setEntities(entities); + } catch (Exception e) { + logger.warn("Failed to get entities from OpenAI: {}", e.getMessage()); + } + + return analytics; + } + + private String generate(String text, String instructionPrefix) { + String prompt = instructionPrefix + text; + + Map message = new HashMap<>(); + 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); + + try { + Map response = this.webClient.post() + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + .bodyValue(requestBody) + .retrieve() + .bodyToMono(new ParameterizedTypeReference>() { + }) + .onErrorResume(err -> { + logger.warn("OpenAI request failed: {}", err.getMessage()); + return Mono.empty(); + }) + .block(Duration.ofMillis(timeoutMs)); + + if (response == null) { + return null; + } + + @SuppressWarnings("unchecked") + 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 content = messageObj.get("content"); + return content == null ? null : String.valueOf(content).trim(); + } + } + return null; + } catch (Exception e) { + logger.warn("Error calling OpenAI generate: {}", e.getMessage()); + return null; + } + } + + public String generateWithInstruction(String text, String instruction) { + return generateWithInstruction(text, instruction, "ru"); + } + + public String generateWithInstruction(String text, String instruction, String language) { + String prompt = instruction + "\n\n" + text; + + Map message = new HashMap<>(); + 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", 2000); + requestBody.put("temperature", 0.3); + + try { + Map response = this.webClient.post() + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + .bodyValue(requestBody) + .retrieve() + .bodyToMono(new ParameterizedTypeReference>() { + }) + .onErrorResume(err -> { + logger.warn("OpenAI request failed: {}", err.getMessage()); + return Mono.empty(); + }) + .block(Duration.ofMillis(timeoutMs)); + + if (response == null) { + return null; + } + + @SuppressWarnings("unchecked") + 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 content = messageObj.get("content"); + String result = content == null ? null : String.valueOf(content).trim(); + System.out.println("OpenAI response: " + result); + return result; + } + } + return null; + } catch (Exception e) { + logger.warn("Error calling OpenAI generate (generic): {}", e.getMessage()); + return null; + } + } +} diff --git a/src/main/java/kz/konturai/parser/service/RbcParserService.java b/src/main/java/kz/konturai/parser/service/RbcParserService.java index 6d8474b..d534db0 100644 --- a/src/main/java/kz/konturai/parser/service/RbcParserService.java +++ b/src/main/java/kz/konturai/parser/service/RbcParserService.java @@ -33,7 +33,7 @@ public class RbcParserService implements ParserService { private MarketItemRepository marketItemRepository; @Autowired - private OllamaAnalyticsService analyticsService; + private OpenAIAnalyticsService analyticsService; @Value("${rss.rbc.url}") private String rssFeedUrl; diff --git a/src/main/java/kz/konturai/parser/service/ReportGenerationService.java b/src/main/java/kz/konturai/parser/service/ReportGenerationService.java index ecb5671..95f4f70 100644 --- a/src/main/java/kz/konturai/parser/service/ReportGenerationService.java +++ b/src/main/java/kz/konturai/parser/service/ReportGenerationService.java @@ -82,18 +82,18 @@ public class ReportGenerationService { } private final MarketItemRepository marketItemRepository; - private final OllamaAnalyticsService ollamaAnalyticsService; + private final OpenAIAnalyticsService openAIAnalyticsService; private final ReportHistoryRepository reportHistoryRepository; private final MinIOService minIOService; private final EmailService emailService; public ReportGenerationService(MarketItemRepository marketItemRepository, - OllamaAnalyticsService ollamaAnalyticsService, + OpenAIAnalyticsService openAIAnalyticsService, ReportHistoryRepository reportHistoryRepository, MinIOService minIOService, EmailService emailService) { this.marketItemRepository = marketItemRepository; - this.ollamaAnalyticsService = ollamaAnalyticsService; + this.openAIAnalyticsService = openAIAnalyticsService; this.reportHistoryRepository = reportHistoryRepository; this.minIOService = minIOService; this.emailService = emailService; @@ -131,7 +131,7 @@ public class ReportGenerationService { + "Не упоминай, что ты ИИ. Используй русский язык.\n\nКонтекст отчёта:\n" + contextBlock + "\nСводки новостей:\n"; - String generated = ollamaAnalyticsService.generateWithInstruction(combined, instruction); + String generated = openAIAnalyticsService.generateWithInstruction(combined, instruction); if (generated == null) { generated = "(Не удалось сгенерировать текст отчёта по предоставленным данным.)"; } @@ -248,7 +248,7 @@ public class ReportGenerationService { return ""; } String instruction = "На основе этих кратких сводок новостей напиши общую аннотацию на 2-3 абзаца, выделяя ключевые тренды и события."; - String res = ollamaAnalyticsService.generateWithInstruction(combined, instruction); + String res = openAIAnalyticsService.generateWithInstruction(combined, instruction); return res == null ? "" : res; } @@ -257,16 +257,16 @@ public class ReportGenerationService { if (combined.isBlank()) { return new Sections("", "", "", ""); } - String annotation = ollamaAnalyticsService.generateWithInstruction(combined, + String annotation = openAIAnalyticsService.generateWithInstruction(combined, "На основе кратких сводок новостей напиши краткое содержание (2-3 абзаца), выделив ключевые тренды и события. Стиль: деловой, нейтральный."); - String intro = ollamaAnalyticsService.generateWithInstruction(combined, + String intro = openAIAnalyticsService.generateWithInstruction(combined, "Напиши краткое введение к аналитическому отчёту за период. Укажи цель отчёта, источники данных и подход к анализу. Объём: 1 абзац."); - String mainPart = ollamaAnalyticsService.generateWithInstruction(combined, + String mainPart = openAIAnalyticsService.generateWithInstruction(combined, "Сформируй основную часть отчёта: перечисли и кратко объясни 3-5 ключевых тенденций и событий периода, добавь замеченные риски и возможности. Используй маркированные пункты и короткие абзацы."); - String recs = ollamaAnalyticsService.generateWithInstruction(combined, + String recs = openAIAnalyticsService.generateWithInstruction(combined, "На основе сводок сформулируй практические рекомендации (4-6 пунктов) для бизнеса/маркетинга. Формат: маркированный список, краткие и конкретные формулировки."); return new Sections(annotation, intro, mainPart, recs); diff --git a/src/main/java/kz/konturai/parser/service/ReportSynthesisService.java b/src/main/java/kz/konturai/parser/service/ReportSynthesisService.java index 87fe294..a005282 100644 --- a/src/main/java/kz/konturai/parser/service/ReportSynthesisService.java +++ b/src/main/java/kz/konturai/parser/service/ReportSynthesisService.java @@ -18,13 +18,13 @@ import java.util.stream.Collectors; @Service public class ReportSynthesisService { - private final OllamaAnalyticsService ollamaAnalyticsService; + private final OpenAIAnalyticsService openAIAnalyticsService; private final OpenAiChartService openAiChartService; private final ObjectMapper objectMapper = new ObjectMapper(); - public ReportSynthesisService(OllamaAnalyticsService ollamaAnalyticsService, + public ReportSynthesisService(OpenAIAnalyticsService openAIAnalyticsService, OpenAiChartService openAiChartService) { - this.ollamaAnalyticsService = ollamaAnalyticsService; + this.openAIAnalyticsService = openAIAnalyticsService; this.openAiChartService = openAiChartService; } @@ -32,7 +32,7 @@ public class ReportSynthesisService { String aggregatedLearnings = formatLearnings(learnings); String prompt = buildTextPrompt(originalQuery, aggregatedLearnings, lang); return Mono - .fromCallable(() -> ollamaAnalyticsService.generateWithInstruction(aggregatedLearnings, prompt, lang)); + .fromCallable(() -> openAIAnalyticsService.generateWithInstruction(aggregatedLearnings, prompt, lang)); } public Mono synthesizeReportAndCharts(String originalQuery, List learnings, @@ -40,7 +40,7 @@ public class ReportSynthesisService { String aggregatedLearnings = formatLearnings(learnings); Mono textMono = Mono.fromCallable( - () -> ollamaAnalyticsService.generateWithInstruction(aggregatedLearnings, + () -> openAIAnalyticsService.generateWithInstruction(aggregatedLearnings, buildTextPrompt(originalQuery, aggregatedLearnings, lang), lang)) .subscribeOn(Schedulers.boundedElastic()); diff --git a/src/main/java/kz/konturai/parser/service/VedomostiParserService.java b/src/main/java/kz/konturai/parser/service/VedomostiParserService.java index 89dc302..8827a18 100644 --- a/src/main/java/kz/konturai/parser/service/VedomostiParserService.java +++ b/src/main/java/kz/konturai/parser/service/VedomostiParserService.java @@ -33,7 +33,7 @@ public class VedomostiParserService implements ParserService { private MarketItemRepository marketItemRepository; @Autowired - private OllamaAnalyticsService analyticsService; + private OpenAIAnalyticsService analyticsService; @Value("${rss.vedomosti.url}") private String rssFeedUrl;