This commit is contained in:
root
2025-10-05 16:40:36 +05:00
parent e908f7840a
commit c07c6e7dc4
6 changed files with 301 additions and 45 deletions
@@ -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);
+5 -3
View File
@@ -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
@@ -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 для синтеза текста.