diff --git a/src/main/java/kz/konturai/parser/service/OpenAiChartService.java b/src/main/java/kz/konturai/parser/service/OpenAiChartService.java new file mode 100644 index 0000000..afa6e6e --- /dev/null +++ b/src/main/java/kz/konturai/parser/service/OpenAiChartService.java @@ -0,0 +1,90 @@ +package kz.konturai.parser.service; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.MediaType; +import org.springframework.http.client.reactive.ReactorClientHttpConnector; +import org.springframework.stereotype.Service; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; +import reactor.netty.http.client.HttpClient; + +import java.time.Duration; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Service +public class OpenAiChartService { + + private final WebClient webClient; + + @Value("${openai.model.name:gpt-4o-mini}") + private String modelName; + + @Value("${openai.timeoutMs:90000}") + private long timeoutMs; + + public OpenAiChartService( + @Value("${openai.api.url:https://api.openai.com/v1/chat/completions}") String apiUrl, + @Value("${openai.api.key:}") String apiKey) { + HttpClient httpClient = HttpClient.create(); + this.webClient = WebClient.builder() + .baseUrl(apiUrl) + .clientConnector(new ReactorClientHttpConnector(httpClient)) + .defaultHeader("Authorization", "Bearer " + apiKey) + .build(); + } + + public Mono getChartDataJson(String aggregatedLearnings) { + String prompt = buildChartDataPrompt(aggregatedLearnings); + return callChatCompletions(prompt); + } + + public Mono getChartSvg(String jsonData) { + String prompt = buildSvgPrompt(jsonData); + return callChatCompletions(prompt); + } + + private Mono callChatCompletions(String prompt) { + Map body = new HashMap<>(); + body.put("model", modelName); + body.put("temperature", 0); + body.put("messages", List.of( + Map.of("role", "user", "content", prompt))); + + return this.webClient.post() + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + .bodyValue(body) + .retrieve() + .bodyToMono(new ParameterizedTypeReference>() { + }) + .timeout(Duration.ofMillis(timeoutMs)) + .map(resp -> { + try { + List> choices = (List>) resp.get("choices"); + if (choices == null || choices.isEmpty()) + return null; + Map choice0 = choices.get(0); + Map message = (Map) choice0.get("message"); + if (message == null) + return null; + Object content = message.get("content"); + return content == null ? null : String.valueOf(content); + } catch (Exception e) { + return null; + } + }); + } + + private String buildChartDataPrompt(String learningsAsString) { + return "Ты — AI-аналитик данных. Внимательно проанализируй следующий текст. Найди в нём все наборы числовых данных, которые можно представить в виде простых диаграмм (столбчатых или круговых). Для КАЖДОГО найденного набора данных создай JSON-объект. Верни ТОЛЬКО JSON-массив этих объектов. Структура каждого объекта: {\\\"chartType\\\": \\\"bar\\\" или \\\"pie\\\", \\\"title\\\": \\\"Название диаграммы\\\", \\\"labels\\\": [\\\"Метка 1\\\"], \\\"data\\\": [число1]}. Если подходящих данных нет, верни пустой массив []. Текст для анализа:\n---\n" + + learningsAsString + "\n---"; + } + + private String buildSvgPrompt(String json) { + return "Ты — эксперт по визуализации данных. На основе следующего JSON, сгенерируй полный и валидный SVG-код для диаграммы. SVG должен быть стильным и читаемым, с подписями на русском языке. Не добавляй никаких комментариев, верни ТОЛЬКО SVG-код. JSON с данными:\n---\n" + + json + "\n---"; + } +} diff --git a/src/main/java/kz/konturai/parser/service/ReportSynthesisService.java b/src/main/java/kz/konturai/parser/service/ReportSynthesisService.java index d0ba2c3..b29fca6 100644 --- a/src/main/java/kz/konturai/parser/service/ReportSynthesisService.java +++ b/src/main/java/kz/konturai/parser/service/ReportSynthesisService.java @@ -19,10 +19,13 @@ import java.util.stream.Collectors; public class ReportSynthesisService { private final OllamaAnalyticsService ollamaAnalyticsService; + private final OpenAiChartService openAiChartService; private final ObjectMapper objectMapper = new ObjectMapper(); - public ReportSynthesisService(OllamaAnalyticsService ollamaAnalyticsService) { + public ReportSynthesisService(OllamaAnalyticsService ollamaAnalyticsService, + OpenAiChartService openAiChartService) { this.ollamaAnalyticsService = ollamaAnalyticsService; + this.openAiChartService = openAiChartService; } public Mono synthesizeReport(String originalQuery, List learnings, String lang) { @@ -40,33 +43,34 @@ public class ReportSynthesisService { buildTextPrompt(originalQuery, aggregatedLearnings, lang))) .subscribeOn(Schedulers.boundedElastic()); - Mono> chartsMono = Mono.fromCallable(() -> { - String jsonArrayString = ollamaAnalyticsService.generateWithInstruction(aggregatedLearnings, - buildChartDataPrompt(aggregatedLearnings)); - String cleanJsonArray = extractJsonArray(jsonArrayString); - if (cleanJsonArray == null || cleanJsonArray.isEmpty() || cleanJsonArray.equals("[]")) { - return new ArrayList(); - } - List chartDataList = objectMapper.readValue(cleanJsonArray, - new TypeReference>() { - }); - List chartImages = new ArrayList<>(); - for (ChartData chartData : chartDataList) { - try { - String chartDataJson = objectMapper.writeValueAsString(chartData); - String rawSvgResponse = ollamaAnalyticsService.generateWithInstruction(chartDataJson, - buildSvgPrompt(chartDataJson)); - String cleanSvg = extractSvgCode(rawSvgResponse); - byte[] png = SvgToPngConverter.convert(cleanSvg); - if (png != null && png.length > 0) { - chartImages.add(png); + Mono> chartsMono = openAiChartService.getChartDataJson(aggregatedLearnings) + .flatMap(jsonArrayString -> { + String cleanJsonArray = extractJsonArray(jsonArrayString); + if (cleanJsonArray == null || cleanJsonArray.isEmpty() || cleanJsonArray.equals("[]")) { + return reactor.core.publisher.Mono.just(new ArrayList()); } - } catch (Exception e) { - System.err.println("Failed to generate a chart: " + e.getMessage()); - } - } - return chartImages; - }).subscribeOn(Schedulers.boundedElastic()); + try { + List chartDataList = objectMapper.readValue(cleanJsonArray, + new TypeReference>() { + }); + return reactor.core.publisher.Flux.fromIterable(chartDataList) + .concatMap(chartData -> { + try { + String chartDataJson = objectMapper.writeValueAsString(chartData); + return openAiChartService.getChartSvg(chartDataJson) + .map(this::extractSvgCode) + .map(SvgToPngConverter::convert) + .filter(Objects::nonNull); + } catch (Exception e) { + return reactor.core.publisher.Mono.empty(); + } + }) + .collectList(); + } catch (Exception e) { + return reactor.core.publisher.Mono.just(new ArrayList()); + } + }) + .subscribeOn(Schedulers.boundedElastic()); return Mono.zip(textMono, chartsMono) .map(tuple -> new FinalReportPayload(tuple.getT1(), tuple.getT2())); diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 7d87dbf..03887b2 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -53,6 +53,12 @@ logging.level.com.mongodb=WARN logging.level.kz.konturai.parser.service.OllamaAnalyticsService=INFO +# 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 + # Email Configuration spring.mail.host=smtp.gmail.com spring.mail.port=587 diff --git a/Техническое Задание: Внедрение гибридной AI-модели (OpenAI для диаграмм, Ollama для текста).md b/Техническое Задание: Внедрение гибридной AI-модели (OpenAI для диаграмм, Ollama для текста).md new file mode 100644 index 0000000..2a06610 --- /dev/null +++ b/Техническое Задание: Внедрение гибридной AI-модели (OpenAI для диаграмм, Ollama для текста).md @@ -0,0 +1,103 @@ +### **Техническое Задание: Внедрение гибридной AI-модели (OpenAI для диаграмм, Ollama для текста)** + +#### **1. Общее описание** + +Целью является доработка AI-агента для использования гибридной модели генерации. Основной синтез текстового отчёта по-прежнему будет выполняться с помощью локального сервиса **Ollama**. Для опциональной и более сложной задачи — извлечения структурированных данных и генерации диаграмм — будет использоваться API **OpenAI**. + +Этот подход позволяет использовать сильные стороны каждой модели: + +- **Ollama:** Экономичная и быстрая генерация основного текста отчёта. +- **OpenAI:** Высокая точность в следовании инструкциям для генерации идеально отформатированного JSON и SVG-кода для диаграмм. + +#### **2. Обновлённая схема работы** + +1. **Сбор данных:** Бэкенд получает `learnings` от `deep-research` API. +2. **Параллельный запуск двух AI-задач:** + - **Ветка A (Текст -\> Ollama):** `learnings` отправляются в ваш существующий `OllamaAnalyticsService` для синтеза основного текста отчёта в Markdown. + - **Ветка B (Диаграммы -\> OpenAI):** `learnings` отправляются в **новый сервис** (`OpenAiChartService`), который делает два последовательных вызова к **API OpenAI**: + 1. Извлечь из текста данные для диаграмм в формате **JSON**. + 2. Превратить этот JSON в **SVG-код** диаграммы. +3. **Конвертация SVG:** SVG-код, полученный от OpenAI, конвертируется в PNG-изображение на стороне Java. +4. **Финальная сборка PDF:** `ResearchPdfService` объединяет текст отчёта (от Ollama) и изображения диаграмм (от OpenAI) в единый PDF-файл. + +#### **3. Требования к реализации** + +**3.1. Конфигурация** + +В ваш файл `application.properties` (или `application.yml`) необходимо добавить API-ключ для OpenAI: + +```properties +# application.properties + +# ... ваши существующие настройки Ollama ... +ollama.model=llama3:8b-instruct + +# --- НОВЫЕ НАСТРОЙКИ ДЛЯ OPENAI --- +openai.api.key=sk-...(ваш API ключ от OpenAI)... +openai.api.url=https://api.openai.com/v1/chat/completions +openai.model.name=gpt-4-turbo # Рекомендуемая модель для работы с JSON и SVG +``` + +**3.2. Создание нового сервиса: `OpenAiChartService`** + +Необходимо создать новый Spring-сервис, отвечающий за взаимодействие с OpenAI. + +- **`WebClient`:** Сервис должен содержать собственный `WebClient`, настроенный на `openai.api.url` и автоматически добавляющий заголовок `Authorization: Bearer ${openai.api.key}` ко всем запросам. +- **Методы:** + 1. `public Mono getChartDataJson(List learnings)`: Принимает `learnings`, использует **Промпт №1** (для извлечения данных) и возвращает `Mono` со строкой, содержащей JSON-массив. + 2. `public Mono getChartSvg(String jsonData)`: Принимает JSON с данными для одной диаграммы, использует **Промпт №2** (для генерации SVG) и возвращает `Mono` со строкой SVG-кода. + +**3.3. Модификация оркестратора: `ReportSynthesisService`** + +Этот сервис теперь будет управлять вызовами к обоим AI-сервисам. + +- **Зависимости:** `ReportSynthesisService` теперь должен инжектировать (`@Autowired`) и `OllamaAnalyticsService`, и новый `OpenAiChartService`. +- **Метод `synthesizeReportAndCharts`:** + - **Ветка текста (`textMono`)** по-прежнему вызывает `ollamaAnalyticsService` для генерации отчёта. + - **Ветка диаграмм (`chartsMono`)** теперь будет вызывать `openAiChartService`. + +**Пример обновлённой логики для `chartsMono`:** + +```java +// ... внутри ReportSynthesisService ... + +// Ветка B: Генерация диаграмм через OpenAI +Mono> chartsMono = openAiChartService.getChartDataJson(aggregatedLearnings) + .flatMap(jsonArrayString -> { + String cleanJsonArray = extractJsonArray(jsonArrayString); + if (cleanJsonArray == null || cleanJsonArray.equals("[]")) { + return Mono.just(Collections.emptyList()); // Если данных нет, возвращаем пустой список + } + + try { + List chartDataList = objectMapper.readValue(cleanJsonArray, new TypeReference>() {}); + + // Превращаем список задач в поток (Flux) и выполняем их последовательно + return Flux.fromIterable(chartDataList) + .concatMap(chartData -> + openAiChartService.getChartSvg(objectMapper.writeValueAsString(chartData)) + ) + .map(this::extractSvgCode) + .map(SvgToPngConverter::convert) + .filter(Objects::nonNull) + .collectList(); + + } catch (IOException e) { + return Mono.error(e); + } + }); +``` + +**3.4. Промпты для OpenAI** + +Промпты остаются теми же, что и в предыдущем ТЗ, так как модели OpenAI отлично их понимают. + +- **Промпт №1 (Извлечение данных):** Просит вернуть **JSON-массив** объектов с данными для диаграмм или пустой массив `[]`, если данных нет. +- **Промпт №2 (Генерация SVG):** Просит на основе одного JSON-объекта вернуть **только SVG-код**. + +#### **4. План действий** + +1. **Добавить `openai.api.key`** в ваш `application.properties`. +2. **Создать новый класс `OpenAiChartService.java`**. Он будет похож на `OllamaAnalyticsService`, но настроен для работы с API OpenAI (другой URL, заголовок авторизации, другая структура JSON-запроса). +3. **Обновить `ReportSynthesisService.java`**, чтобы он использовал `OllamaAnalyticsService` для текста и `OpenAiChartService` для диаграмм, как показано в примере выше. +4. **Перезапустить** ваше Java-приложение.