.
This commit is contained in:
@@ -11,6 +11,7 @@ import kz.konturai.parser.repository.ReportHistoryRepository;
|
||||
import kz.konturai.parser.service.ReportGenerationService;
|
||||
import kz.konturai.parser.service.DeepResearchService;
|
||||
import kz.konturai.parser.service.ResearchPdfService;
|
||||
import kz.konturai.parser.service.ReportSynthesisService;
|
||||
import kz.konturai.parser.service.MinIOService;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
@@ -38,17 +39,20 @@ public class ReportController {
|
||||
private final MinIOService minIOService;
|
||||
private final DeepResearchService deepResearchService;
|
||||
private final ResearchPdfService researchPdfService;
|
||||
private final ReportSynthesisService reportSynthesisService;
|
||||
|
||||
public ReportController(ReportGenerationService reportGenerationService,
|
||||
ReportHistoryRepository reportHistoryRepository,
|
||||
MinIOService minIOService,
|
||||
DeepResearchService deepResearchService,
|
||||
ResearchPdfService researchPdfService) {
|
||||
ResearchPdfService researchPdfService,
|
||||
ReportSynthesisService reportSynthesisService) {
|
||||
this.reportGenerationService = reportGenerationService;
|
||||
this.reportHistoryRepository = reportHistoryRepository;
|
||||
this.minIOService = minIOService;
|
||||
this.deepResearchService = deepResearchService;
|
||||
this.researchPdfService = researchPdfService;
|
||||
this.reportSynthesisService = reportSynthesisService;
|
||||
}
|
||||
|
||||
@PostMapping("/generate")
|
||||
@@ -96,21 +100,30 @@ public class ReportController {
|
||||
// Предполагаемое время завершения
|
||||
LocalDateTime estimatedCompletion = LocalDateTime.now().plusMinutes(8);
|
||||
|
||||
// Запускаем асинхронный процесс: deep-research -> PDF -> сохранение
|
||||
// Запускаем асинхронный процесс: deep-research -> synthesis via Ollama -> PDF
|
||||
// -> сохранение
|
||||
deepResearchService.generateReportAsync(deepRequest)
|
||||
.publishOn(Schedulers.boundedElastic())
|
||||
.map(researchResponse -> {
|
||||
.flatMap(researchResponse -> {
|
||||
if (researchResponse == null
|
||||
|| researchResponse.getMainContent() == null
|
||||
|| researchResponse.getMainContent().trim().isEmpty()) {
|
||||
throw new RuntimeException("Empty research response content");
|
||||
|| researchResponse.getLearnings() == null
|
||||
|| researchResponse.getLearnings().isEmpty()) {
|
||||
return reactor.core.publisher.Mono.error(
|
||||
new RuntimeException(
|
||||
"Empty research response content"));
|
||||
}
|
||||
System.out.println("researchResponse:" + researchResponse.getMainContent());
|
||||
System.out.println("researchResponse:" + researchResponse.getLearnings());
|
||||
System.out.println("researchResponse:" + researchResponse.getVisitedUrls());
|
||||
System.out.println("researchResponse:" + researchResponse.getStatus());
|
||||
return reportSynthesisService
|
||||
.synthesizeReport(request.getQuery(),
|
||||
researchResponse.getLearnings(),
|
||||
request.getLang())
|
||||
.map(synthesizedMarkdown -> new Object[] { researchResponse,
|
||||
synthesizedMarkdown });
|
||||
})
|
||||
.map(tuple -> {
|
||||
DeepResearchResponse researchResponse = (DeepResearchResponse) tuple[0];
|
||||
String synthesizedMarkdown = (String) tuple[1];
|
||||
byte[] pdfBytes = researchPdfService.generatePdfReport(request.getQuery(),
|
||||
researchResponse);
|
||||
synthesizedMarkdown, researchResponse.getVisitedUrls());
|
||||
String filename = "research_report_" + System.currentTimeMillis() + ".pdf";
|
||||
saveResearchReportToHistory(request, filename, pdfBytes, researchResponse);
|
||||
return true;
|
||||
|
||||
@@ -64,46 +64,28 @@ public class DeepResearchResponse {
|
||||
/**
|
||||
* Получить основной текст отчёта (report или answer + learnings)
|
||||
*/
|
||||
// public String getMainContent() {
|
||||
// StringBuilder contentBuilder = new StringBuilder();
|
||||
|
||||
// // Сначала добавляем answer, если есть
|
||||
// if (answer != null && !answer.trim().isEmpty()) {
|
||||
// contentBuilder.append(answer).append("\n\n");
|
||||
// }
|
||||
|
||||
// // Затем добавляем learnings, если они есть
|
||||
// if (learnings != null && !learnings.isEmpty()) {
|
||||
// contentBuilder.append("### Подробные выводы:\n");
|
||||
// for (String learning : learnings) {
|
||||
// contentBuilder.append("- ").append(learning).append("\n");
|
||||
// }
|
||||
// contentBuilder.append("\n");
|
||||
// }
|
||||
|
||||
// // Также можно добавить report, если вдруг deep-research начнет его
|
||||
// возвращать
|
||||
// // if (report != null && !report.trim().isEmpty()) {
|
||||
// // contentBuilder.append(report).append("\n\n");
|
||||
// // }
|
||||
|
||||
// return contentBuilder.toString().trim();
|
||||
// }
|
||||
|
||||
public String getMainContent() {
|
||||
StringBuilder contentBuilder = new StringBuilder();
|
||||
|
||||
// Используем краткий 'answer' как вступительное предложение.
|
||||
// Сначала добавляем answer, если есть
|
||||
if (answer != null && !answer.trim().isEmpty()) {
|
||||
contentBuilder.append(answer).append(" "); // Добавляем пробел для соединения
|
||||
contentBuilder.append(answer).append("\n\n");
|
||||
}
|
||||
|
||||
// Объединяем все learnings в одну строку, разделяя их пробелами.
|
||||
// Затем добавляем learnings, если они есть
|
||||
if (learnings != null && !learnings.isEmpty()) {
|
||||
String singleArticleText = String.join(" ", learnings);
|
||||
contentBuilder.append(singleArticleText);
|
||||
contentBuilder.append("### Подробные выводы:\n");
|
||||
for (String learning : learnings) {
|
||||
contentBuilder.append("- ").append(learning).append("\n");
|
||||
}
|
||||
contentBuilder.append("\n");
|
||||
}
|
||||
|
||||
// Также можно добавить report, если вдруг deep-research начнет его возвращать
|
||||
// if (report != null && !report.trim().isEmpty()) {
|
||||
// contentBuilder.append(report).append("\n\n");
|
||||
// }
|
||||
|
||||
return contentBuilder.toString().trim();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package kz.konturai.parser.service;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
public class ReportSynthesisService {
|
||||
|
||||
private final OllamaAnalyticsService ollamaAnalyticsService;
|
||||
|
||||
public ReportSynthesisService(OllamaAnalyticsService ollamaAnalyticsService) {
|
||||
this.ollamaAnalyticsService = ollamaAnalyticsService;
|
||||
}
|
||||
|
||||
public Mono<String> synthesizeReport(String originalQuery, List<String> learnings, String lang) {
|
||||
String aggregatedLearnings = formatLearnings(learnings);
|
||||
String prompt = buildPrompt(originalQuery, aggregatedLearnings, lang);
|
||||
return Mono.fromCallable(() -> ollamaAnalyticsService.generateWithInstruction(aggregatedLearnings, prompt));
|
||||
}
|
||||
|
||||
private String formatLearnings(List<String> learnings) {
|
||||
if (learnings == null || learnings.isEmpty()) {
|
||||
return "- (данные не были предоставлены)";
|
||||
}
|
||||
List<String> cleaned = learnings.stream()
|
||||
.filter(Objects::nonNull)
|
||||
.map(String::trim)
|
||||
.filter(s -> !s.isEmpty())
|
||||
.collect(Collectors.toList());
|
||||
if (cleaned.isEmpty()) {
|
||||
return "- (данные не были предоставлены)";
|
||||
}
|
||||
return cleaned.stream().map(s -> "- " + s).collect(Collectors.joining("\n"));
|
||||
}
|
||||
|
||||
private String buildPrompt(String originalQuery, String learningsAsString, String lang) {
|
||||
return "Ты — профессиональный AI-аналитик. Твоя задача — написать подробный и структурированный отчёт на основе предоставленных данных. Не добавляй никаких предисловий или комментариев от себя, просто верни готовый отчёт.\n\n"
|
||||
+ "### Тема исследования:\n\n"
|
||||
+ originalQuery + "\n\n"
|
||||
+ "### Собранные данные (тезисы):\n\n"
|
||||
+ learningsAsString + "\n\n"
|
||||
+ "### Твоя задача:\n\n"
|
||||
+ "1. **Язык:** Напиши отчёт полностью на " + (lang == null ? "русском" : lang)
|
||||
+ " языке.\n"
|
||||
+ "2. **Структура:** Отчёт должен иметь чёткую структуру: введение, основная часть с несколькими разделами и подразделами, и заключение.\n"
|
||||
+ "3. **Форматирование:** Используй Markdown. Для главных заголовков разделов используй `##`, для подзаголовков — `###`. Для списков используй `-`.\n"
|
||||
+ "4. **Стиль:** Преврати разрозненные тезисы в единую, связную и хорошо написанную статью. Не просто перечисляй факты, а анализируй и обобщай их.\n"
|
||||
+ "5. **Ограничение:** Используй только ту информацию, которая дана в собранных данных. Не придумывай ничего от себя.\n\n"
|
||||
+ "Начинай отчёт с главного заголовка.";
|
||||
}
|
||||
}
|
||||
@@ -110,6 +110,30 @@ public class ResearchPdfService {
|
||||
}
|
||||
}
|
||||
|
||||
public byte[] generatePdfReport(String query, String synthesizedMarkdown, java.util.List<String> visitedUrls) {
|
||||
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);
|
||||
addSources(document, visitedUrls);
|
||||
|
||||
document.close();
|
||||
return outputStream.toByteArray();
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Failed to generate PDF report (synthesized)", e);
|
||||
}
|
||||
}
|
||||
|
||||
private void addTitlePage(Document document, String query) throws DocumentException {
|
||||
// Этот метод был в порядке, оставляем без изменений
|
||||
Paragraph title = new Paragraph(query, titleFont);
|
||||
@@ -191,6 +215,51 @@ public class ResearchPdfService {
|
||||
document.add(chapter);
|
||||
}
|
||||
|
||||
private void addMainReportFromMarkdown(Document document, String markdown) throws DocumentException {
|
||||
String cleanedContent = cleanContent(markdown);
|
||||
if (cleanedContent == null || cleanedContent.isEmpty()) {
|
||||
cleanedContent = "## Отчёт не был сгенерирован.\n\nДанные для анализа отсутствуют.";
|
||||
}
|
||||
|
||||
document.newPage();
|
||||
|
||||
String[] lines = cleanedContent.split("\n");
|
||||
Chapter chapter = new Chapter(new Paragraph("Основной отчёт", headingFont), 1);
|
||||
chapter.setNumberDepth(0);
|
||||
|
||||
com.lowagie.text.List currentList = null;
|
||||
|
||||
for (String line : lines) {
|
||||
if (line.trim().isEmpty())
|
||||
continue;
|
||||
if (!(line.startsWith("- ") || line.startsWith("* ")) && currentList != null) {
|
||||
chapter.add(currentList);
|
||||
currentList = null;
|
||||
}
|
||||
if (line.startsWith("# ")) {
|
||||
chapter.add(new Paragraph(line.substring(2), headingFont));
|
||||
} else if (line.startsWith("## ")) {
|
||||
chapter.add(new Paragraph(line.substring(3), subheadingFont));
|
||||
} else if (line.startsWith("- ") || line.startsWith("* ")) {
|
||||
if (currentList == null) {
|
||||
currentList = new com.lowagie.text.List(com.lowagie.text.List.UNORDERED);
|
||||
currentList.setListSymbol(new Chunk("- ", normalFont));
|
||||
}
|
||||
currentList.add(new ListItem(line.substring(2), normalFont));
|
||||
} else {
|
||||
Paragraph para = new Paragraph(line, normalFont);
|
||||
para.setSpacingAfter(10);
|
||||
chapter.add(para);
|
||||
}
|
||||
}
|
||||
|
||||
if (currentList != null) {
|
||||
chapter.add(currentList);
|
||||
}
|
||||
|
||||
document.add(chapter);
|
||||
}
|
||||
|
||||
private void addSources(Document document, List<String> visitedUrls) throws DocumentException {
|
||||
document.newPage();
|
||||
Paragraph sourcesTitle = new Paragraph("Источники", headingFont);
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
ollama.host=http://185.35.223.45:11434
|
||||
ollama.model=gemma3:1b
|
||||
ollama.timeoutMs=180000
|
||||
ollama.connectTimeoutMs=10000
|
||||
ollama.responseTimeoutMs=180000
|
||||
# MinIO configuration
|
||||
minio.endpoint=http://92.38.48.166:9000
|
||||
minio.access-key=z9HhZTjzhVWqw3D20u7q
|
||||
@@ -46,9 +51,6 @@ logging.level.org.springframework.scheduling=DEBUG
|
||||
logging.level.org.springframework.data.mongodb=INFO
|
||||
logging.level.com.mongodb=WARN
|
||||
|
||||
# Ollama AI analytics configuration
|
||||
ollama.host=http://185.35.223.45:11434
|
||||
ollama.model=gemma3:1b
|
||||
logging.level.kz.konturai.parser.service.OllamaAnalyticsService=INFO
|
||||
|
||||
# Email Configuration
|
||||
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
Проект: Доработка модуля генерации отчётов в существующем Java/Spring Boot бэкенде.
|
||||
|
||||
1. Общее описание
|
||||
Целью является создание "Агента-синтезатора" внутри бэкенда. Этот агент будет использовать существующий сервис OllamaAnalyticsService для взаимодействия с локально развёрнутой языковой моделью Ollama. Задача агента — принять "сырые" тезисы (learnings) от сервиса deep-research и с помощью Ollama сгенерировать из них связный, структурированный и стилистически выверенный текстовый отчёт в формате Markdown.
|
||||
|
||||
2. Требования к реализации
|
||||
2.1. Создание нового сервиса: ReportSynthesisService
|
||||
|
||||
Необходимо создать новый Spring-сервис (@Service).
|
||||
|
||||
Этот сервис будет зависеть от уже существующего OllamaAnalyticsService и должен инжектировать его (@Autowired).
|
||||
|
||||
Сервис должен иметь один публичный метод:
|
||||
|
||||
Java
|
||||
|
||||
public Mono<String> synthesizeReport(String originalQuery, List<String> learnings, String lang)
|
||||
Вход:
|
||||
|
||||
originalQuery: Исходная тема исследования.
|
||||
|
||||
learnings: Список "сырых" тезисов от deep-research.
|
||||
|
||||
lang: Целевой язык отчёта.
|
||||
|
||||
Выход: Mono<String>, содержащий готовый отчёт в формате Markdown.
|
||||
|
||||
2.2. Логика работы ReportSynthesisService
|
||||
|
||||
Метод synthesizeReport получает на вход данные.
|
||||
|
||||
Он объединяет список learnings в единый блок текста, где каждый тезис начинается с новой строки (например, с помощью String.join("\n- ", learnings)).
|
||||
|
||||
Он формирует промпт (инструкцию) для модели Ollama (см. п. 2.3).
|
||||
|
||||
Ключевой шаг: Он вызывает метод generateWithInstruction из вашего существующего OllamaAnalyticsService:
|
||||
|
||||
Java
|
||||
|
||||
// Преобразуем асинхронный вызов в Mono
|
||||
Mono<String> synthesizedReportMono = Mono.fromCallable(() ->
|
||||
ollamaAnalyticsService.generateWithInstruction(aggregatedLearnings, prompt)
|
||||
);
|
||||
Метод возвращает synthesizedReportMono.
|
||||
|
||||
2.3. Разработка промпта для Ollama
|
||||
|
||||
Промпт — это сердце качественного результата. Он должен быть чётким и структурированным. Этот промпт будет реализован как форматированная строка внутри ReportSynthesisService.
|
||||
|
||||
Пример промпта для Ollama (модели типа Llama3, Gemma):
|
||||
|
||||
Ты — профессиональный AI-аналитик. Твоя задача — написать подробный и структурированный отчёт на основе предоставленных данных. Не добавляй никаких предисловий или комментариев от себя, просто верни готовый отчёт.
|
||||
|
||||
### Тема исследования:
|
||||
|
||||
${originalQuery}
|
||||
|
||||
### Собранные данные (тезисы):
|
||||
|
||||
- ${learnings_as_string}
|
||||
|
||||
### Твоя задача:
|
||||
|
||||
1. **Язык:** Напиши отчёт полностью на ${lang} языке.
|
||||
2. **Структура:** Отчёт должен иметь чёткую структуру: введение, основная часть с несколькими разделами и подразделами, и заключение.
|
||||
3. **Форматирование:** Используй Markdown. Для главных заголовков разделов используй `##`, для подзаголовков — `###`. Для списков используй `-`.
|
||||
4. **Стиль:** Преврати разрозненные тезисы в единую, связную и хорошо написанную статью. Не просто перечисляй факты, а анализируй и обобщай их.
|
||||
5. **Ограничение:** Используй только ту информацию, которая дана в собранных данных. Не придумывай ничего от себя.
|
||||
|
||||
Начинай отчёт с главного заголовка.
|
||||
2.4. Интеграция в существующий асинхронный процесс
|
||||
|
||||
Необходимо модифицировать ваш существующий метод в контроллере, который запускает весь процесс. Вместо вызова OpenAI, теперь будет вызываться ваш новый ReportSynthesisService.
|
||||
|
||||
Примерная схема интеграции (на основе вашего кода):
|
||||
|
||||
Java
|
||||
|
||||
// ...
|
||||
// Инжектируем новый сервис
|
||||
@Autowired
|
||||
private ReportSynthesisService reportSynthesisService;
|
||||
|
||||
// ... в вашем методе generateResearchReport
|
||||
deepResearchService.generateReportAsync(deepRequest)
|
||||
.publishOn(Schedulers.boundedElastic())
|
||||
.flatMap(researchResponse -> { // Используем flatMap для асинхронной цепочки
|
||||
if (researchResponse == null || researchResponse.getLearnings() == null || researchResponse.getLearnings().isEmpty()) {
|
||||
return Mono.error(new RuntimeException("Empty research response content"));
|
||||
}
|
||||
// НОВЫЙ ШАГ: Вызываем наш сервис синтеза с Ollama
|
||||
return reportSynthesisService.synthesizeReport(
|
||||
request.getQuery(),
|
||||
researchResponse.getLearnings(),
|
||||
request.getLang()
|
||||
).map(synthesizedText -> new SynthesizedReport(researchResponse, synthesizedText)); // Объединяем результаты
|
||||
})
|
||||
.map(synthesizedReport -> {
|
||||
// Здесь мы используем synthesizedText для генерации PDF
|
||||
// Этот код остаётся почти без изменений
|
||||
byte[] pdfBytes = researchPdfService.generatePdfReport(
|
||||
request.getQuery(),
|
||||
synthesizedReport.getOriginalResponse(),
|
||||
synthesizedReport.getSynthesizedMarkdown() // Передаем новый красивый текст от Ollama
|
||||
);
|
||||
String filename = "research*report*" + System.currentTimeMillis() + ".pdf";
|
||||
saveResearchReportToHistory(request, filename, pdfBytes, synthesizedReport.getOriginalResponse());
|
||||
return true;
|
||||
})
|
||||
.doOnError(e -> {
|
||||
System.err.println("Error in full research chain: " + e.getMessage());
|
||||
})
|
||||
.subscribe();
|
||||
// ...
|
||||
Вам также понадобится немного изменить researchPdfService.generatePdfReport, чтобы он принимал новый синтезированный Markdown-текст в качестве основного контента.
|
||||
|
||||
3. Конфигурация
|
||||
Вся конфигурация для Ollama уже есть в вашем OllamaAnalyticsService. Новых настроек добавлять не нужно.
|
||||
|
||||
Важная рекомендация: Убедитесь, что в вашем application.properties (или yml) для ollama.model указана достаточно мощная модель, способная следовать сложным инструкциям. Модель gemma3:1b слишком слабая для такой задачи. Рекомендуется использовать:
|
||||
|
||||
llama3:8b-instruct (хороший баланс)
|
||||
|
||||
gemma:7b-it
|
||||
|
||||
mistral или mixtral
|
||||
|
||||
Пример:
|
||||
|
||||
Properties
|
||||
|
||||
ollama.host=http://185.35.223.45:11434
|
||||
ollama.model=llama3:8b-instruct
|
||||
ollama.timeoutMs=180000 # Увеличьте таймаут до 3 минут, т.к. генерация отчёта - долгая задача 4. Ожидаемый результат
|
||||
После реализации бэкенд будет использовать ваш собственный, локально развёрнутый Ollama для создания высококачественных, структурированных PDF-отчётов, полностью контролируя весь процесс и не завися от внешних платных API для синтеза текста.
|
||||
Reference in New Issue
Block a user