This commit is contained in:
root
2025-10-06 17:38:10 +05:00
parent 129302c136
commit 40626e9a14
7 changed files with 321 additions and 7 deletions
+7
View File
@@ -113,6 +113,13 @@
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
<!-- Apache Batik for SVG -> PNG conversion -->
<dependency>
<groupId>org.apache.xmlgraphics</groupId>
<artifactId>batik-transcoder</artifactId>
<version>1.17</version>
</dependency>
</dependencies>
<build>
@@ -1,6 +1,7 @@
package kz.konturai.parser.controller;
import kz.konturai.parser.dto.ReportGenerateRequest;
import kz.konturai.parser.dto.FinalReportPayload;
import kz.konturai.parser.dto.ReportGenerationResponse;
import kz.konturai.parser.dto.ApiResponse;
import kz.konturai.parser.dto.ResearchReportRequest;
@@ -113,17 +114,17 @@ public class ReportController {
"Empty research response content"));
}
return reportSynthesisService
.synthesizeReport(request.getQuery(),
.synthesizeReportAndCharts(request.getQuery(),
researchResponse.getLearnings(),
request.getLang())
.map(synthesizedMarkdown -> new Object[] { researchResponse,
synthesizedMarkdown });
.map(payload -> new Object[] { researchResponse, payload });
})
.map(tuple -> {
DeepResearchResponse researchResponse = (DeepResearchResponse) tuple[0];
String synthesizedMarkdown = (String) tuple[1];
FinalReportPayload payload = (FinalReportPayload) tuple[1];
byte[] pdfBytes = researchPdfService.generatePdfReport(request.getQuery(),
synthesizedMarkdown, researchResponse.getVisitedUrls());
payload.getMarkdownContent(), researchResponse.getVisitedUrls(),
payload.getChartImages());
String filename = "research_report_" + System.currentTimeMillis() + ".pdf";
saveResearchReportToHistory(request, filename, pdfBytes, researchResponse);
return true;
@@ -0,0 +1,34 @@
package kz.konturai.parser.dto;
import java.util.ArrayList;
import java.util.List;
public class FinalReportPayload {
public String markdownContent;
public List<byte[]> chartImages;
public FinalReportPayload() {
this.chartImages = new ArrayList<>();
}
public FinalReportPayload(String markdownContent, List<byte[]> chartImages) {
this.markdownContent = markdownContent;
this.chartImages = chartImages == null ? new ArrayList<>() : chartImages;
}
public String getMarkdownContent() {
return markdownContent;
}
public void setMarkdownContent(String markdownContent) {
this.markdownContent = markdownContent;
}
public List<byte[]> getChartImages() {
return chartImages;
}
public void setChartImages(List<byte[]> chartImages) {
this.chartImages = chartImages;
}
}
@@ -1,8 +1,11 @@
package kz.konturai.parser.service;
import kz.konturai.parser.dto.FinalReportPayload;
import kz.konturai.parser.util.SvgToPngConverter;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Mono;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.stream.Collectors;
@@ -18,10 +21,37 @@ public class ReportSynthesisService {
public Mono<String> synthesizeReport(String originalQuery, List<String> learnings, String lang) {
String aggregatedLearnings = formatLearnings(learnings);
String prompt = buildPrompt(originalQuery, aggregatedLearnings, lang);
String prompt = buildTextPrompt(originalQuery, aggregatedLearnings, lang);
return Mono.fromCallable(() -> ollamaAnalyticsService.generateWithInstruction(aggregatedLearnings, prompt));
}
public Mono<FinalReportPayload> synthesizeReportAndCharts(String originalQuery, List<String> learnings,
String lang) {
String aggregatedLearnings = formatLearnings(learnings);
Mono<String> textMono = Mono.fromCallable(
() -> ollamaAnalyticsService.generateWithInstruction(aggregatedLearnings,
buildTextPrompt(originalQuery, aggregatedLearnings, lang)));
Mono<List<byte[]>> chartsMono = Mono.fromCallable(() -> {
String json = ollamaAnalyticsService.generateWithInstruction(aggregatedLearnings,
buildChartDataPrompt(aggregatedLearnings));
if (json == null || json.trim().isEmpty() || json.trim().equals("{}")) {
return new ArrayList<byte[]>();
}
String svg = ollamaAnalyticsService.generateWithInstruction(json, buildSvgPrompt(json));
byte[] png = SvgToPngConverter.convertSvgToPng(svg);
List<byte[]> list = new ArrayList<>();
if (png != null && png.length > 0) {
list.add(png);
}
return list;
});
return Mono.zip(textMono, chartsMono)
.map(tuple -> new FinalReportPayload(tuple.getT1(), tuple.getT2()));
}
private String formatLearnings(List<String> learnings) {
if (learnings == null || learnings.isEmpty()) {
return "- (данные не были предоставлены)";
@@ -37,7 +67,7 @@ public class ReportSynthesisService {
return cleaned.stream().map(s -> "- " + s).collect(Collectors.joining("\n"));
}
private String buildPrompt(String originalQuery, String learningsAsString, String lang) {
private String buildTextPrompt(String originalQuery, String learningsAsString, String lang) {
return "Ты — профессиональный AI-аналитик. Твоя задача — написать подробный и структурированный отчёт на основе предоставленных данных. Не добавляй никаких предисловий или комментариев от себя, просто верни готовый отчёт.\n\n"
+ "### Тема исследования:\n\n"
+ originalQuery + "\n\n"
@@ -52,4 +82,14 @@ public class ReportSynthesisService {
+ "5. **Ограничение:** Используй только ту информацию, которая дана в собранных данных. Не придумывай ничего от себя.\n\n"
+ "Начинай отчёт с главного заголовка.";
}
private String buildChartDataPrompt(String learningsAsString) {
return "Ты — AI-аналитик данных. Внимательно проанализируй следующий текст. Если найдёшь в нём числовые данные, которые можно представить в виде простой столбчатой или круговой диаграммы (например, рост по годам, доли рынка, сравнение показателей), верни ТОЛЬКО один JSON-объект со структурой: {\\\"chartType\\\": \\\"bar\\\" или \\\"pie\\\", \\\"title\\\": \\\"Название диаграммы\\\", \\\"labels\\\": [\\\"Метка 1\\\", \\\"Метка 2\\\"], \\\"data\\\": [число1, число2]}. Если подходящих данных нет, верни пустой JSON-объект {}. Текст для анализа:\n---\n"
+ learningsAsString + "\n---";
}
private String buildSvgPrompt(String json) {
return "Ты — эксперт по визуализации данных. На основе следующего JSON, сгенерируй полный и валидный SVG-код для диаграммы. SVG должен быть стильным и читаемым, с подписями на русском языке. Не добавляй никаких комментариев, верни ТОЛЬКО SVG-код. JSON с данными:\n---\n"
+ json + "\n---";
}
}
@@ -164,6 +164,64 @@ public class ResearchPdfService {
}
}
public byte[] generatePdfReport(String query, String synthesizedMarkdown, java.util.List<String> visitedUrls,
java.util.List<byte[]> chartImages) {
try {
int contentLength = synthesizedMarkdown == null ? 0 : synthesizedMarkdown.length();
int urlCount = visitedUrls == null ? 0 : visitedUrls.size();
int charts = chartImages == null ? 0 : chartImages.size();
logger.info("PDF input (with charts): query='{}', contentLength={}, visitedUrlsCount={}, charts={}", query,
contentLength, urlCount, charts);
} catch (Exception logEx) {
}
try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
Document document = new Document(PageSize.A4, 36, 36, 36, 54);
PdfWriter writer = PdfWriter.getInstance(document, outputStream);
writer.setPageEvent(new PageFooter(footerFont));
document.addTitle(query);
document.addAuthor("AI Research Agent");
document.addSubject("Research Report");
document.open();
addTitlePage(document, query);
addMainReportFromMarkdown(document, synthesizedMarkdown);
addChartsSection(document, chartImages);
addSources(document, visitedUrls);
document.close();
return outputStream.toByteArray();
} catch (Exception e) {
throw new RuntimeException("Failed to generate PDF report (with charts)", e);
}
}
private void addChartsSection(Document document, java.util.List<byte[]> chartImages) throws DocumentException {
if (chartImages == null || chartImages.isEmpty()) {
return;
}
document.newPage();
Paragraph title = new Paragraph("Визуализация данных", headingFont);
Chapter chapter = new Chapter(title, 3);
chapter.setNumberDepth(0);
for (byte[] imageData : chartImages) {
try {
Image chartImage = Image.getInstance(imageData);
float scaler = ((document.getPageSize().getWidth() - document.leftMargin() - document.rightMargin())
/ chartImage.getWidth()) * 80;
chartImage.scalePercent(scaler);
chartImage.setAlignment(Element.ALIGN_CENTER);
chapter.add(new Paragraph(" "));
chapter.add(chartImage);
} catch (Exception e) {
System.err.println("Не удалось добавить изображение диаграммы в PDF: " + e.getMessage());
}
}
document.add(chapter);
}
private void addTitlePage(Document document, String query) throws DocumentException {
// Этот метод был в порядке, оставляем без изменений
Paragraph title = new Paragraph(query, titleFont);
@@ -0,0 +1,41 @@
package kz.konturai.parser.util;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.lang.reflect.Constructor;
import java.lang.reflect.Method;
import java.nio.charset.StandardCharsets;
public class SvgToPngConverter {
public static byte[] convertSvgToPng(String svg) {
if (svg == null || svg.isBlank()) {
return null;
}
try {
Class<?> pngTranscoderClass = Class.forName("org.apache.batik.transcoder.image.PNGTranscoder");
Object transcoder = pngTranscoderClass.getDeclaredConstructor().newInstance();
Class<?> transcoderInputClass = Class.forName("org.apache.batik.transcoder.TranscoderInput");
Class<?> transcoderOutputClass = Class.forName("org.apache.batik.transcoder.TranscoderOutput");
ByteArrayInputStream in = new ByteArrayInputStream(svg.getBytes(StandardCharsets.UTF_8));
Constructor<?> inputCtor = transcoderInputClass.getConstructor(java.io.InputStream.class);
Object input = inputCtor.newInstance(in);
ByteArrayOutputStream out = new ByteArrayOutputStream();
Constructor<?> outputCtor = transcoderOutputClass.getConstructor(java.io.OutputStream.class);
Object output = outputCtor.newInstance(out);
Method transcode = pngTranscoderClass.getMethod("transcode", transcoderInputClass, transcoderOutputClass);
transcode.invoke(transcoder, input, output);
out.flush();
return out.toByteArray();
} catch (ClassNotFoundException e) {
return null;
} catch (Exception e) {
return null;
}
}
}
@@ -0,0 +1,133 @@
Техническое Задание: Опциональное добавление диаграмм в PDF-отчёты
1. Общее описание
Целью является улучшение существующих PDF-отчётов путём опционального добавления в них диаграмм и графиков. Основной процесс генерации текстового отчёта на основе learnings остаётся без изменений. Параллельно с этим, AI-агент будет пытаться найти в "сырых" данных (learnings) числовую информацию, подходящую для визуализации.
Если такие данные найдены, агент сгенерирует диаграмму и вставит её в PDF. Если данных нет, будет сгенерирован обычный текстовый отчёт без диаграмм. Процесс не должен прерываться, если сгенерировать диаграмму невозможно.
2. Обновлённая схема работы
Процесс будет состоять из двух параллельных веток, которые затем объединяются:
Сбор данных: Бэкенд вызывает deep-research API и получает learnings и visitedUrls.
Запускаются два асинхронных вызова к Ollama:
Ветка A (Синтез текста - основная): Все learnings отправляются в Ollama с промптом на написание красивого, структурированного текстового отчёта в формате Markdown. (Этот шаг остаётся без изменений).
Ветка B (Поиск данных для диаграммы - опциональная): Все learnings отправляются в Ollama с новым, специальным промптом для поиска числовых данных и возврата их в виде JSON.
Обработка Ветки B (если успешно):
Если Ollama вернул валидный JSON с данными, делается третий вызов к Ollama с промптом на генерацию SVG-кода этой диаграммы.
Shutterstock
* Полученный SVG-код конвертируется в изображение (PNG) на стороне Java.
Финальная сборка PDF:
ResearchPdfService получает обязательный текстовый отчёт из Ветки А, опциональное изображение диаграммы из Ветки B и список visitedUrls.
Он собирает всё это в единый PDF-документ и возвращает пользователю.
3. Требования к реализации
3.1. Модификация сервиса синтеза (например, ReportSynthesisService)
Этот сервис теперь будет оркестрировать несколько асинхронных вызовов.
Основной метод synthesizeReportAndCharts будет возвращать объект, содержащий и текст, и изображения, например Mono<FinalReportPayload>.
Java
class FinalReportPayload {
String markdownContent;
List<byte[]> chartImages;
// ...
}
Внутри этого метода будут асинхронно запускаться два вызова к Ollama:
generateTextReport(learnings) - для получения текста.
generateChartData(learnings) - для получения JSON для диаграммы.
3.2. Промпт-инжиниринг для диаграмм (Ветка B)
Шаг 1: Извлечение данных в JSON
Задача: Найти в тексте данные для визуализации.
Промпт:
"Ты — AI-аналитик данных. Внимательно проанализируй следующий текст. Если найдёшь в нём числовые данные, которые можно представить в виде простой столбчатой или круговой диаграммы (например, рост по годам, доли рынка, сравнение показателей), верни ТОЛЬКО один JSON-объект со структурой: {\"chartType\": \"bar\" или \"pie\", \"title\": \"Название диаграммы\", \"labels\": [\"Метка 1\", \"Метка 2\"], \"data\": [число1, число2]}. Если подходящих данных нет, верни пустой JSON-объект {}. Текст для анализа:\n---\n${learnings_as_string}\n---"
Шаг 2: Генерация SVG из JSON
Задача: Превратить JSON с данными в SVG-код.
Промпт:
"Ты — эксперт по визуализации данных. На основе следующего JSON, сгенерируй полный и валидный SVG-код для диаграммы. SVG должен быть стильным и читаемым, с подписями на русском языке. Не добавляй никаких комментариев, верни ТОЛЬКО SVG-код. JSON с данными:\n---\n${json_from_previous_step}\n---"
3.3. Конвертация SVG в изображение
Этот модуль остаётся без изменений. Используется библиотека Apache Batik для преобразования SVG-строки в byte[] PNG-изображения.
Зависимость в pom.xml:
XML
<dependency>
<groupId>org.apache.xmlgraphics</groupId>
<artifactId>batik-transcoder</artifactId>
<version>1.17</version>
</dependency>
Вспомогательный класс-конвертер остаётся тем же.
3.4. Модификация PDF-сервиса (ResearchPdfService)
Сервис должен быть готов к тому, что диаграмм может и не быть.
Измените метод generatePdfReport, чтобы он принимал список изображений, который может быть пустым:
Java
public byte[] generatePdfReport(String query, String markdownContent, List<String> visitedUrls, List<byte[]> chartImages)
Внутри метода добавьте проверку: если список chartImages не пустой, вставляйте изображения в документ.
Java
// ... после вставки основного текста отчёта ...
if (chartImages != null && !chartImages.isEmpty()) {
document.newPage();
document.add(new Paragraph("Визуализация данных", headingFont));
for (byte[] imageData : chartImages) {
try {
Image chartImage = Image.getInstance(imageData);
// Масштабируем, чтобы поместилось на страницу
float scaler = ((document.getPageSize().getWidth() - document.leftMargin() - document.rightMargin()) / chartImage.getWidth()) * 80; // 80% ширины
chartImage.scalePercent(scaler);
chartImage.setAlignment(Element.ALIGN_CENTER);
document.add(new Paragraph(" ")); // Отступ
document.add(chartImage);
} catch (Exception e) {
// Логируем ошибку, но не прерываем генерацию PDF
System.err.println("Не удалось добавить изображение диаграммы в PDF: " + e.getMessage());
}
}
}
4. План действий
Добавить зависимость Apache Batik в pom.xml.
Реализовать асинхронную логику в ReportSynthesisService, которая параллельно запрашивает у Ollama:
Основной текст отчёта.
JSON с данными для диаграммы.
Добавить в тот же сервис условную логику: если JSON получен, сделать ещё один запрос к Ollama за SVG-кодом.
Реализовать SVG-конвертер (или добавить его как util-класс).
Обновить ResearchPdfService, чтобы он мог принимать и вставлять список изображений, если он не пуст.