openai
This commit is contained in:
@@ -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<String> getChartDataJson(String aggregatedLearnings) {
|
||||||
|
String prompt = buildChartDataPrompt(aggregatedLearnings);
|
||||||
|
return callChatCompletions(prompt);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Mono<String> getChartSvg(String jsonData) {
|
||||||
|
String prompt = buildSvgPrompt(jsonData);
|
||||||
|
return callChatCompletions(prompt);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Mono<String> callChatCompletions(String prompt) {
|
||||||
|
Map<String, Object> 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<Map<String, Object>>() {
|
||||||
|
})
|
||||||
|
.timeout(Duration.ofMillis(timeoutMs))
|
||||||
|
.map(resp -> {
|
||||||
|
try {
|
||||||
|
List<Map<String, Object>> choices = (List<Map<String, Object>>) resp.get("choices");
|
||||||
|
if (choices == null || choices.isEmpty())
|
||||||
|
return null;
|
||||||
|
Map<String, Object> choice0 = choices.get(0);
|
||||||
|
Map<String, Object> message = (Map<String, Object>) 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---";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,10 +19,13 @@ import java.util.stream.Collectors;
|
|||||||
public class ReportSynthesisService {
|
public class ReportSynthesisService {
|
||||||
|
|
||||||
private final OllamaAnalyticsService ollamaAnalyticsService;
|
private final OllamaAnalyticsService ollamaAnalyticsService;
|
||||||
|
private final OpenAiChartService openAiChartService;
|
||||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||||
|
|
||||||
public ReportSynthesisService(OllamaAnalyticsService ollamaAnalyticsService) {
|
public ReportSynthesisService(OllamaAnalyticsService ollamaAnalyticsService,
|
||||||
|
OpenAiChartService openAiChartService) {
|
||||||
this.ollamaAnalyticsService = ollamaAnalyticsService;
|
this.ollamaAnalyticsService = ollamaAnalyticsService;
|
||||||
|
this.openAiChartService = openAiChartService;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Mono<String> synthesizeReport(String originalQuery, List<String> learnings, String lang) {
|
public Mono<String> synthesizeReport(String originalQuery, List<String> learnings, String lang) {
|
||||||
@@ -40,33 +43,34 @@ public class ReportSynthesisService {
|
|||||||
buildTextPrompt(originalQuery, aggregatedLearnings, lang)))
|
buildTextPrompt(originalQuery, aggregatedLearnings, lang)))
|
||||||
.subscribeOn(Schedulers.boundedElastic());
|
.subscribeOn(Schedulers.boundedElastic());
|
||||||
|
|
||||||
Mono<List<byte[]>> chartsMono = Mono.fromCallable(() -> {
|
Mono<List<byte[]>> chartsMono = openAiChartService.getChartDataJson(aggregatedLearnings)
|
||||||
String jsonArrayString = ollamaAnalyticsService.generateWithInstruction(aggregatedLearnings,
|
.flatMap(jsonArrayString -> {
|
||||||
buildChartDataPrompt(aggregatedLearnings));
|
String cleanJsonArray = extractJsonArray(jsonArrayString);
|
||||||
String cleanJsonArray = extractJsonArray(jsonArrayString);
|
if (cleanJsonArray == null || cleanJsonArray.isEmpty() || cleanJsonArray.equals("[]")) {
|
||||||
if (cleanJsonArray == null || cleanJsonArray.isEmpty() || cleanJsonArray.equals("[]")) {
|
return reactor.core.publisher.Mono.just(new ArrayList<byte[]>());
|
||||||
return new ArrayList<byte[]>();
|
|
||||||
}
|
|
||||||
List<ChartData> chartDataList = objectMapper.readValue(cleanJsonArray,
|
|
||||||
new TypeReference<List<ChartData>>() {
|
|
||||||
});
|
|
||||||
List<byte[]> 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);
|
|
||||||
}
|
}
|
||||||
} catch (Exception e) {
|
try {
|
||||||
System.err.println("Failed to generate a chart: " + e.getMessage());
|
List<ChartData> chartDataList = objectMapper.readValue(cleanJsonArray,
|
||||||
}
|
new TypeReference<List<ChartData>>() {
|
||||||
}
|
});
|
||||||
return chartImages;
|
return reactor.core.publisher.Flux.fromIterable(chartDataList)
|
||||||
}).subscribeOn(Schedulers.boundedElastic());
|
.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<byte[]>());
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.subscribeOn(Schedulers.boundedElastic());
|
||||||
|
|
||||||
return Mono.zip(textMono, chartsMono)
|
return Mono.zip(textMono, chartsMono)
|
||||||
.map(tuple -> new FinalReportPayload(tuple.getT1(), tuple.getT2()));
|
.map(tuple -> new FinalReportPayload(tuple.getT1(), tuple.getT2()));
|
||||||
|
|||||||
@@ -53,6 +53,12 @@ logging.level.com.mongodb=WARN
|
|||||||
|
|
||||||
logging.level.kz.konturai.parser.service.OllamaAnalyticsService=INFO
|
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
|
# Email Configuration
|
||||||
spring.mail.host=smtp.gmail.com
|
spring.mail.host=smtp.gmail.com
|
||||||
spring.mail.port=587
|
spring.mail.port=587
|
||||||
|
|||||||
+103
@@ -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<String> getChartDataJson(List<String> learnings)`: Принимает `learnings`, использует **Промпт №1** (для извлечения данных) и возвращает `Mono` со строкой, содержащей JSON-массив.
|
||||||
|
2. `public Mono<String> 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<List<byte[]>> chartsMono = openAiChartService.getChartDataJson(aggregatedLearnings)
|
||||||
|
.flatMap(jsonArrayString -> {
|
||||||
|
String cleanJsonArray = extractJsonArray(jsonArrayString);
|
||||||
|
if (cleanJsonArray == null || cleanJsonArray.equals("[]")) {
|
||||||
|
return Mono.just(Collections.emptyList()); // Если данных нет, возвращаем пустой список
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
List<ChartData> chartDataList = objectMapper.readValue(cleanJsonArray, new TypeReference<List<ChartData>>() {});
|
||||||
|
|
||||||
|
// Превращаем список задач в поток (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-приложение.
|
||||||
Reference in New Issue
Block a user