This commit is contained in:
root
2025-10-05 02:56:17 +05:00
parent 18a363f772
commit 3caf1cce28
17 changed files with 1571 additions and 78 deletions
+198
View File
@@ -0,0 +1,198 @@
# Руководство по интеграции отправки отчетов по email
## Обзор
В систему добавлена функциональность автоматической отправки сгенерированных PDF отчетов на email пользователя. После генерации отчета в формате PDF, система автоматически отправит его на указанный email адрес.
## Настройка
### 1. Конфигурация email в application.properties
```properties
# Email Configuration
spring.mail.host=smtp.gmail.com
spring.mail.port=587
spring.mail.username=your-email@gmail.com
spring.mail.password=your-app-password
spring.mail.properties.mail.smtp.auth=true
spring.mail.properties.mail.smtp.starttls.enable=true
spring.mail.properties.mail.smtp.starttls.required=true
```
**Важно:** Для Gmail необходимо использовать App Password вместо обычного пароля.
### 2. Настройка Gmail App Password
1. Включите двухфакторную аутентификацию в Google аккаунте
2. Перейдите в настройки безопасности Google
3. Создайте App Password для приложения
4. Используйте этот пароль в конфигурации
## Использование
### API Endpoint
**POST** `/api/parser/report/generate`
### Пример запроса
```json
{
"reportTitle": "Аналитический отчет за декабрь 2024",
"authorName": "Иван Петров",
"companyName": "ООО Контурай",
"startDate": "2024-12-01T00:00:00",
"endDate": "2024-12-31T23:59:59",
"format": "PDF",
"recipientEmail": "user@example.com"
}
```
### Параметры
- `reportTitle` - название отчета
- `authorName` - имя автора отчета
- `companyName` - название компании
- `startDate` - дата начала периода (ISO 8601)
- `endDate` - дата окончания периода (ISO 8601)
- `format` - формат отчета ("PDF" или "DOCX")
- `recipientEmail` - **НОВОЕ ПОЛЕ** email адрес для отправки отчета
### Условия отправки email
Email отправляется только при выполнении следующих условий:
1. Указан `recipientEmail` (не пустой)
2. Формат отчета установлен как "PDF"
3. Отчет успешно сгенерирован
### Формат письма
Письмо содержит:
- Красиво оформленный HTML текст с деталями отчета
- Прикрепленный PDF файл с отчетом
- Информацию об авторе, компании и дате создания
## Компоненты системы
### EmailService
Новый сервис для отправки email:
- `sendReportByEmail()` - основной метод отправки
- Поддержка HTML формата писем
- Прикрепление PDF файлов
- Обработка ошибок
### ReportGenerationService
Обновлен для интеграции с EmailService:
- Автоматическая отправка PDF после генерации
- Обработка ошибок отправки без прерывания процесса
- Поддержка асинхронной генерации с email
### ReportGenerateRequest
Добавлено новое поле:
- `recipientEmail` - email для отправки отчета
## Тестирование
### Unit тесты
Создан `EmailServiceTest` с тестами:
- Успешная отправка email
- Обработка null значений
- Обработка ошибок SMTP
### Ручное тестирование
1. Настройте email конфигурацию
2. Отправьте POST запрос с валидными данными
3. Проверьте получение email с PDF вложением
## Безопасность
- Email отправляется только на указанный адрес
- Используется TLS шифрование для SMTP
- App Password для Gmail вместо обычного пароля
- Обработка ошибок без раскрытия чувствительной информации
## Мониторинг
- Логирование успешных отправок
- Логирование ошибок отправки
- Не прерывает процесс генерации отчета при ошибках email
## Примеры использования
### cURL
```bash
curl -X POST http://localhost:8080/api/parser/report/generate \
-H "Content-Type: application/json" \
-d '{
"reportTitle": "Тестовый отчет",
"authorName": "Тест Автор",
"companyName": "Тест Компания",
"startDate": "2024-12-01T00:00:00",
"endDate": "2024-12-31T23:59:59",
"format": "PDF",
"recipientEmail": "test@example.com"
}'
```
### JavaScript (fetch)
```javascript
const response = await fetch('/api/parser/report/generate', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
reportTitle: 'Аналитический отчет',
authorName: 'Иван Петров',
companyName: 'ООО Контурай',
startDate: '2024-12-01T00:00:00',
endDate: '2024-12-31T23:59:59',
format: 'PDF',
recipientEmail: 'user@example.com',
}),
});
const result = await response.json();
console.log(result);
```
## Troubleshooting
### Проблемы с Gmail
1. **Ошибка аутентификации**: Убедитесь, что используете App Password
2. **Блокировка аккаунта**: Проверьте настройки безопасности Gmail
3. **Порт заблокирован**: Попробуйте порт 465 с SSL
### Общие проблемы
1. **Email не отправляется**: Проверьте логи приложения
2. **PDF не прикрепляется**: Убедитесь, что формат "PDF"
3. **Пустой recipientEmail**: Проверьте, что поле заполнено
## Логи
Успешная отправка:
```
Email с отчетом успешно отправлен на: user@example.com
```
Ошибка отправки:
```
Ошибка при отправке email: [детали ошибки]
```
+119
View File
@@ -0,0 +1,119 @@
# Резюме реализации AI-агента для генерации PDF-отчётов
## Выполненные задачи
### ✅ 1. Создание DTO классов
- `DeepResearchRequest.java` - для запросов к deep-research API
- `DeepResearchResponse.java` - для ответов от deep-research API
- `ResearchReportRequest.java` - для валидации входящих запросов
### ✅ 2. Реализация HTTP клиента
- `DeepResearchService.java` - сервис для взаимодействия с deep-research API
- Поддержка синхронных и асинхронных вызовов
- Обработка ошибок и таймаутов
### ✅ 3. Генерация PDF отчётов
- `ResearchPdfService.java` - сервис для создания PDF с профессиональным оформлением
- Титульная страница с названием исследования
- Автоматически генерируемое содержание
- Структурированный основной текст
- Список источников из visitedUrls
### ✅ 4. Модификация ReportController
- Добавлен новый эндпоинт `POST /api/parser/report`
- Валидация входных параметров
- Обработка ошибок (400, 500, 504)
- Сохранение отчётов в историю и MinIO
### ✅ 5. Обновление конфигурации
- Добавлены настройки deep-research API в `application.properties`
- Добавлена зависимость Jackson в `pom.xml`
- Настроены таймауты и URL для внешнего API
## API Спецификация
### Эндпоинт
```
POST /api/parser/report
```
### Параметры запроса
```json
{
"query": "string (обязательный)",
"lang": "string (по умолчанию: ru)",
"depth": "integer 1-5 (по умолчанию: 3)",
"breadth": "integer 2-10 (по умолчанию: 5)",
"report_type": "string (по умолчанию: report)"
}
```
### Ответы
- **200 OK**: PDF файл с отчётом
- **400 Bad Request**: Некорректные параметры
- **500 Internal Server Error**: Внутренняя ошибка
- **504 Gateway Timeout**: Таймаут внешнего API
## Структура PDF-отчёта
1. **Титульная страница**
- Название исследования
- Дата создания
- Подзаголовок
2. **Содержание**
- Автоматически генерируемое оглавление
3. **Основная часть**
- Введение
- Текст отчёта от deep-research API
- Заключение
4. **Источники**
- Список URL из visitedUrls
## Конфигурация
```properties
# Deep Research API Configuration
deep-research.api.url=http://185.35.223.45:3051
deep-research.api.timeout=300000
```
## Тестирование
Создан тестовый скрипт `test_research_api.sh` для проверки функциональности.
## Документация
Создано подробное руководство `RESEARCH_API_GUIDE.md` с примерами использования.
## Соответствие техническому заданию
✅ Все требования из ТЗ выполнены:
- Модифицирован эндпоинт `/api/parser/report`
- Реализована интеграция с deep-research API
- Добавлена генерация PDF с требуемой структурой
- Настроена обработка ошибок
- Добавлена валидация параметров
- Реализовано сохранение в историю отчётов
## Готовность к использованию
Система готова к тестированию и использованию. Для запуска необходимо:
1. Убедиться, что deep-research API доступен
2. Запустить приложение
3. Отправить POST запрос на `/api/parser/report`
+188
View File
@@ -0,0 +1,188 @@
# Руководство по API генерации исследовательских отчётов
## Обзор
Новый эндпоинт `/api/parser/report` интегрирован с сервисом `deep-research` для генерации PDF-отчётов на основе пользовательских запросов.
## Эндпоинт
**POST** `/api/parser/report`
### Параметры запроса
| Параметр | Тип | Обязательный | По умолчанию | Описание |
| ------------- | ------- | ------------ | ------------ | ------------------------------- |
| `query` | string | ✅ | - | Тема для исследования |
| `lang` | string | ❌ | "ru" | Язык отчёта ("ru", "en") |
| `depth` | integer | ❌ | 3 | Глубина исследования (1-5) |
| `breadth` | integer | ❌ | 5 | Широта исследования (2-10) |
| `report_type` | string | ❌ | "report" | Тип отчёта ("report", "answer") |
### Пример запроса
```json
{
"query": "Искусственный интеллект в здравоохранении",
"lang": "ru",
"depth": 3,
"breadth": 5,
"report_type": "report"
}
```
### Ответы
#### Успешный ответ (200 OK)
- **Content-Type**: `application/pdf`
- **Content-Disposition**: `attachment; filename="research_report_[timestamp].pdf"`
- **Тело**: PDF-файл с отчётом
#### Ошибки
| Код | Описание |
| --- | ----------------------------------------- |
| 400 | Некорректные параметры запроса |
| 500 | Внутренняя ошибка сервера |
| 504 | Таймаут при обращении к deep-research API |
## Структура PDF-отчёта
Сгенерированный PDF содержит:
1. **Титульная страница**
- Название исследования (из поля `query`)
- Дата создания
- Подзаголовок "Исследовательский отчёт"
2. **Содержание**
- Автоматически сгенерированное оглавление
3. **Основная часть**
- Введение
- Основной текст отчёта от deep-research API
- Заключение
4. **Источники**
- Список URL-адресов из поля `visitedUrls`
## Конфигурация
Настройки в `application.properties`:
```properties
# Deep Research API Configuration
deep-research.api.url=http://185.35.223.45:3051
deep-research.api.timeout=300000
```
## Примеры использования
### cURL
```bash
curl -X POST "http://localhost:8080/api/parser/report" \
-H "Content-Type: application/json" \
-d '{
"query": "Блокчейн технологии в финансах",
"lang": "ru",
"depth": 4,
"breadth": 6
}' \
--output "blockchain_report.pdf"
```
### JavaScript (fetch)
```javascript
const response = await fetch('/api/parser/report', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
query: 'Квантовые вычисления',
lang: 'ru',
depth: 3,
breadth: 5,
report_type: 'report',
}),
});
if (response.ok) {
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'quantum_computing_report.pdf';
a.click();
}
```
### Python (requests)
```python
import requests
response = requests.post(
'http://localhost:8080/api/parser/report',
json={
'query': 'Машинное обучение в медицине',
'lang': 'ru',
'depth': 3,
'breadth': 5,
'report_type': 'report'
}
)
if response.status_code == 200:
with open('ml_medicine_report.pdf', 'wb') as f:
f.write(response.content)
print("Отчёт сохранён как ml_medicine_report.pdf")
else:
print(f"Ошибка: {response.status_code}")
```
## Тестирование
Для тестирования API используйте скрипт `test_research_api.sh`:
```bash
./test_research_api.sh
```
## Логирование
Все операции логируются. Для отладки проверьте логи приложения:
```bash
tail -f logs/application.log | grep "DeepResearchService\|ResearchPdfService"
```
## Ограничения
- Максимальное время ожидания: 5 минут (300 секунд)
- Размер генерируемого PDF ограничен только ресурсами сервера
- Deep-research API должен быть доступен по указанному URL
## Устранение неполадок
### Ошибка 504 (Gateway Timeout)
- Проверьте доступность deep-research API
- Увеличьте timeout в конфигурации
- Проверьте сетевые настройки
### Ошибка 500 (Internal Server Error)
- Проверьте логи приложения
- Убедитесь, что все зависимости установлены
- Проверьте конфигурацию MinIO для сохранения файлов
### Пустой PDF
- Проверьте, что deep-research API возвращает корректные данные
- Убедитесь, что поле `report` или `answer` в ответе не пустое
+12
View File
@@ -101,6 +101,18 @@
<artifactId>minio</artifactId>
<version>8.5.7</version>
</dependency>
<!-- Spring Boot Mail Starter for email functionality -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-mail</artifactId>
</dependency>
<!-- Jackson for JSON processing -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
</dependencies>
<build>
@@ -3,9 +3,14 @@ package kz.konturai.parser.controller;
import kz.konturai.parser.dto.ReportGenerateRequest;
import kz.konturai.parser.dto.ReportGenerationResponse;
import kz.konturai.parser.dto.ApiResponse;
import kz.konturai.parser.dto.ResearchReportRequest;
import kz.konturai.parser.dto.DeepResearchRequest;
import kz.konturai.parser.dto.DeepResearchResponse;
import kz.konturai.parser.model.ReportHistory;
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.MinIOService;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
@@ -27,84 +32,171 @@ import kz.konturai.parser.dto.PageResponse;
@RequestMapping("/api/parser/report")
public class ReportController {
private final ReportGenerationService reportGenerationService;
private final ReportHistoryRepository reportHistoryRepository;
private final MinIOService minIOService;
private final ReportGenerationService reportGenerationService;
private final ReportHistoryRepository reportHistoryRepository;
private final MinIOService minIOService;
private final DeepResearchService deepResearchService;
private final ResearchPdfService researchPdfService;
public ReportController(ReportGenerationService reportGenerationService,
ReportHistoryRepository reportHistoryRepository,
MinIOService minIOService) {
this.reportGenerationService = reportGenerationService;
this.reportHistoryRepository = reportHistoryRepository;
this.minIOService = minIOService;
}
@PostMapping("/generate")
public ResponseEntity<ApiResponse<ReportGenerationResponse>> generate(
@Validated @RequestBody ReportGenerateRequest request) {
// Generate unique task ID
String taskId = UUID.randomUUID().toString();
// Start async report generation
reportGenerationService.generateAsync(request);
// Calculate estimated completion time (5-10 minutes for complex reports)
LocalDateTime estimatedCompletion = LocalDateTime.now().plusMinutes(8);
ReportGenerationResponse response = new ReportGenerationResponse(
taskId,
"Отчёт будет сгенерирован в фоновом режиме. Ожидаемое время завершения: "
+ estimatedCompletion.format(java.time.format.DateTimeFormatter.ofPattern("HH:mm")),
estimatedCompletion,
"PROCESSING");
return ResponseEntity.ok(ApiResponse.success("Отчёт поставлен в очередь на генерацию", response));
}
@GetMapping("/history")
public ResponseEntity<PageResponse<ReportHistory>> history(
@RequestParam(name = "page", defaultValue = "0") int page,
@RequestParam(name = "size", defaultValue = "20") int size,
@RequestParam(name = "sort", defaultValue = "createdAt,desc") String sort) {
String[] parts = sort.split(",");
String sortField = parts.length > 0 ? parts[0] : "createdAt";
Sort.Direction dir = parts.length > 1 && parts[1].equalsIgnoreCase("asc") ? Sort.Direction.ASC
: Sort.Direction.DESC;
Pageable pageable = PageRequest.of(page, size, Sort.by(dir, sortField));
Page<ReportHistory> res = reportHistoryRepository.findAll(pageable);
PageResponse<ReportHistory> dto = new PageResponse<>(
res.getContent(),
res.getNumber(),
res.getSize(),
res.getTotalElements(),
res.getTotalPages(),
sort);
return ResponseEntity.ok(dto);
}
@GetMapping("/history/{id}")
public ResponseEntity<byte[]> download(@PathVariable("id") String id) {
Optional<ReportHistory> opt = reportHistoryRepository.findById(id);
if (opt.isEmpty()) {
return ResponseEntity.notFound().build();
public ReportController(ReportGenerationService reportGenerationService,
ReportHistoryRepository reportHistoryRepository,
MinIOService minIOService,
DeepResearchService deepResearchService,
ResearchPdfService researchPdfService) {
this.reportGenerationService = reportGenerationService;
this.reportHistoryRepository = reportHistoryRepository;
this.minIOService = minIOService;
this.deepResearchService = deepResearchService;
this.researchPdfService = researchPdfService;
}
ReportHistory rh = opt.get();
try {
// Download from MinIO
java.io.InputStream inputStream = minIOService.downloadFile(rh.getFilePath());
byte[] bytes = inputStream.readAllBytes();
inputStream.close();
MediaType mt = rh.getFormat() != null && rh.getFormat().equalsIgnoreCase("DOCX")
? MediaType
.parseMediaType("application/vnd.openxmlformats-officedocument.wordprocessingml.document")
: MediaType.APPLICATION_PDF;
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + rh.getFilename() + "\"")
.contentType(mt)
.body(bytes);
} catch (Exception e) {
return ResponseEntity.internalServerError().build();
@PostMapping("/generate")
public ResponseEntity<ApiResponse<ReportGenerationResponse>> generate(
@Validated @RequestBody ReportGenerateRequest request) {
// Generate unique task ID
String taskId = UUID.randomUUID().toString();
// Start async report generation
reportGenerationService.generateAsync(request);
// Calculate estimated completion time (5-10 minutes for complex reports)
LocalDateTime estimatedCompletion = LocalDateTime.now().plusMinutes(8);
ReportGenerationResponse response = new ReportGenerationResponse(
taskId,
"Отчёт будет сгенерирован в фоновом режиме. Ожидаемое время завершения: "
+ estimatedCompletion.format(
java.time.format.DateTimeFormatter.ofPattern("HH:mm")),
estimatedCompletion,
"PROCESSING");
return ResponseEntity.ok(ApiResponse.success("Отчёт поставлен в очередь на генерацию", response));
}
@PostMapping
public ResponseEntity<byte[]> generateResearchReport(@Validated @RequestBody ResearchReportRequest request) {
try {
// Валидация входных данных
if (request.getQuery() == null || request.getQuery().trim().isEmpty()) {
return ResponseEntity.badRequest().build();
}
// Подготовка запроса для deep-research API
DeepResearchRequest deepRequest = new DeepResearchRequest(
request.getQuery(),
request.getLang(),
request.getDepth(),
request.getBreadth(),
request.getReportType());
// Вызов deep-research API
DeepResearchResponse researchResponse = deepResearchService.generateReport(deepRequest);
if (researchResponse == null || researchResponse.getMainContent().trim().isEmpty()) {
return ResponseEntity.internalServerError().build();
}
// Генерация PDF
byte[] pdfBytes = researchPdfService.generatePdfReport(request.getQuery(), researchResponse);
// Формирование имени файла
String filename = "research_report_" + System.currentTimeMillis() + ".pdf";
// Сохранение в историю отчётов
saveResearchReportToHistory(request, filename, pdfBytes, researchResponse);
// Возврат PDF файла
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename=\"" + filename + "\"")
.contentType(MediaType.APPLICATION_PDF)
.body(pdfBytes);
} catch (Exception e) {
// Логирование ошибки
System.err.println("Error generating research report: " + e.getMessage());
e.printStackTrace();
// Возврат ошибки в зависимости от типа исключения
if (e.getMessage() != null && e.getMessage().contains("timeout")) {
return ResponseEntity.status(504).build(); // Gateway Timeout
} else {
return ResponseEntity.status(500).build(); // Internal Server Error
}
}
}
private void saveResearchReportToHistory(ResearchReportRequest request, String filename,
byte[] pdfBytes, DeepResearchResponse researchResponse) {
try {
// Загрузка в MinIO
minIOService.uploadFile(filename, pdfBytes, MediaType.APPLICATION_PDF.toString());
// Сохранение в базу данных
ReportHistory reportHistory = new ReportHistory();
reportHistory.setReportTitle(request.getQuery());
reportHistory.setAuthorName("AI Research Agent");
reportHistory.setCompanyName("Deep Research Service");
reportHistory.setStartDate(LocalDateTime.now());
reportHistory.setEndDate(LocalDateTime.now());
reportHistory.setFormat("PDF");
reportHistory.setFilename(filename);
reportHistory.setFilePath(filename);
reportHistory.setFileSize(pdfBytes.length);
reportHistory.setCreatedAt(LocalDateTime.now());
reportHistoryRepository.save(reportHistory);
} catch (Exception e) {
System.err.println("Error saving research report to history: " + e.getMessage());
// Не прерываем выполнение, если не удалось сохранить в историю
}
}
@GetMapping("/history")
public ResponseEntity<PageResponse<ReportHistory>> history(
@RequestParam(name = "page", defaultValue = "0") int page,
@RequestParam(name = "size", defaultValue = "20") int size,
@RequestParam(name = "sort", defaultValue = "createdAt,desc") String sort) {
String[] parts = sort.split(",");
String sortField = parts.length > 0 ? parts[0] : "createdAt";
Sort.Direction dir = parts.length > 1 && parts[1].equalsIgnoreCase("asc") ? Sort.Direction.ASC
: Sort.Direction.DESC;
Pageable pageable = PageRequest.of(page, size, Sort.by(dir, sortField));
Page<ReportHistory> res = reportHistoryRepository.findAll(pageable);
PageResponse<ReportHistory> dto = new PageResponse<>(
res.getContent(),
res.getNumber(),
res.getSize(),
res.getTotalElements(),
res.getTotalPages(),
sort);
return ResponseEntity.ok(dto);
}
@GetMapping("/history/{id}")
public ResponseEntity<byte[]> download(@PathVariable("id") String id) {
Optional<ReportHistory> opt = reportHistoryRepository.findById(id);
if (opt.isEmpty()) {
return ResponseEntity.notFound().build();
}
ReportHistory rh = opt.get();
try {
// Download from MinIO
java.io.InputStream inputStream = minIOService.downloadFile(rh.getFilePath());
byte[] bytes = inputStream.readAllBytes();
inputStream.close();
MediaType mt = rh.getFormat() != null && rh.getFormat().equalsIgnoreCase("DOCX")
? MediaType
.parseMediaType("application/vnd.openxmlformats-officedocument.wordprocessingml.document")
: MediaType.APPLICATION_PDF;
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename=\"" + rh.getFilename() + "\"")
.contentType(mt)
.body(bytes);
} catch (Exception e) {
return ResponseEntity.internalServerError().build();
}
}
}
}
@@ -0,0 +1,72 @@
package kz.konturai.parser.dto;
import com.fasterxml.jackson.annotation.JsonProperty;
public class DeepResearchRequest {
@JsonProperty("query")
private String query;
@JsonProperty("lang")
private String lang = "ru";
@JsonProperty("depth")
private Integer depth = 3;
@JsonProperty("breadth")
private Integer breadth = 5;
@JsonProperty("report_type")
private String reportType = "report";
public DeepResearchRequest() {
}
public DeepResearchRequest(String query, String lang, Integer depth, Integer breadth, String reportType) {
this.query = query;
this.lang = lang;
this.depth = depth;
this.breadth = breadth;
this.reportType = reportType;
}
public String getQuery() {
return query;
}
public void setQuery(String query) {
this.query = query;
}
public String getLang() {
return lang;
}
public void setLang(String lang) {
this.lang = lang;
}
public Integer getDepth() {
return depth;
}
public void setDepth(Integer depth) {
this.depth = depth;
}
public Integer getBreadth() {
return breadth;
}
public void setBreadth(Integer breadth) {
this.breadth = breadth;
}
public String getReportType() {
return reportType;
}
public void setReportType(String reportType) {
this.reportType = reportType;
}
}
@@ -0,0 +1,75 @@
package kz.konturai.parser.dto;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.List;
public class DeepResearchResponse {
@JsonProperty("report")
private String report;
@JsonProperty("answer")
private String answer;
@JsonProperty("visitedUrls")
private List<String> visitedUrls;
@JsonProperty("status")
private String status;
@JsonProperty("error")
private String error;
public DeepResearchResponse() {
}
public String getReport() {
return report;
}
public void setReport(String report) {
this.report = report;
}
public String getAnswer() {
return answer;
}
public void setAnswer(String answer) {
this.answer = answer;
}
public List<String> getVisitedUrls() {
return visitedUrls;
}
public void setVisitedUrls(List<String> visitedUrls) {
this.visitedUrls = visitedUrls;
}
public String getStatus() {
return status;
}
public void setStatus(String status) {
this.status = status;
}
public String getError() {
return error;
}
public void setError(String error) {
this.error = error;
}
/**
* Получить основной текст отчёта (report или answer)
*/
public String getMainContent() {
if (report != null && !report.trim().isEmpty()) {
return report;
}
return answer != null ? answer : "";
}
}
@@ -10,6 +10,7 @@ public class ReportGenerateRequest {
private LocalDateTime startDate;
private LocalDateTime endDate;
private String format; // PDF or DOCX
private String recipientEmail; // Email для отправки отчета
public String getReportTitle() {
return reportTitle;
@@ -58,4 +59,12 @@ public class ReportGenerateRequest {
public void setFormat(String format) {
this.format = format;
}
public String getRecipientEmail() {
return recipientEmail;
}
public void setRecipientEmail(String recipientEmail) {
this.recipientEmail = recipientEmail;
}
}
@@ -0,0 +1,74 @@
package kz.konturai.parser.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.Max;
public class ResearchReportRequest {
@NotBlank(message = "Query is required")
private String query;
private String lang = "ru";
@Min(value = 1, message = "Depth must be between 1 and 5")
@Max(value = 5, message = "Depth must be between 1 and 5")
private Integer depth = 3;
@Min(value = 2, message = "Breadth must be between 2 and 10")
@Max(value = 10, message = "Breadth must be between 2 and 10")
private Integer breadth = 5;
private String reportType = "report";
public ResearchReportRequest() {
}
public ResearchReportRequest(String query, String lang, Integer depth, Integer breadth, String reportType) {
this.query = query;
this.lang = lang;
this.depth = depth;
this.breadth = breadth;
this.reportType = reportType;
}
public String getQuery() {
return query;
}
public void setQuery(String query) {
this.query = query;
}
public String getLang() {
return lang;
}
public void setLang(String lang) {
this.lang = lang;
}
public Integer getDepth() {
return depth;
}
public void setDepth(Integer depth) {
this.depth = depth;
}
public Integer getBreadth() {
return breadth;
}
public void setBreadth(Integer breadth) {
this.breadth = breadth;
}
public String getReportType() {
return reportType;
}
public void setReportType(String reportType) {
this.reportType = reportType;
}
}
@@ -0,0 +1,66 @@
package kz.konturai.parser.service;
import kz.konturai.parser.dto.DeepResearchRequest;
import kz.konturai.parser.dto.DeepResearchResponse;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Service;
import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.reactive.function.client.WebClientResponseException;
import reactor.core.publisher.Mono;
import java.time.Duration;
@Service
public class DeepResearchService {
private final WebClient webClient;
@Value("${deep-research.api.url:http://185.35.223.45:3051}")
private String apiUrl;
@Value("${deep-research.api.timeout:300000}") // 5 minutes default timeout
private long timeoutMs;
public DeepResearchService() {
this.webClient = WebClient.builder()
.defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)
.build();
}
/**
* Вызывает deep-research API для генерации отчёта
*/
public DeepResearchResponse generateReport(DeepResearchRequest request) {
try {
return webClient.post()
.uri(apiUrl + "/api/research")
.bodyValue(request)
.retrieve()
.bodyToMono(DeepResearchResponse.class)
.timeout(Duration.ofMillis(timeoutMs))
.block();
} catch (WebClientResponseException e) {
throw new RuntimeException("Deep research API error: " + e.getResponseBodyAsString(), e);
} catch (Exception e) {
throw new RuntimeException("Failed to call deep research API", e);
}
}
/**
* Асинхронный вызов deep-research API
*/
public Mono<DeepResearchResponse> generateReportAsync(DeepResearchRequest request) {
return webClient.post()
.uri(apiUrl + "/api/research")
.bodyValue(request)
.retrieve()
.bodyToMono(DeepResearchResponse.class)
.timeout(Duration.ofMillis(timeoutMs))
.onErrorMap(WebClientResponseException.class,
e -> new RuntimeException("Deep research API error: " + e.getResponseBodyAsString(), e))
.onErrorMap(Exception.class, e -> new RuntimeException("Failed to call deep research API", e));
}
}
@@ -0,0 +1,119 @@
package kz.konturai.parser.service;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.stereotype.Service;
import jakarta.mail.MessagingException;
import jakarta.mail.internet.MimeMessage;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
@Service
public class EmailService {
private final JavaMailSender mailSender;
public EmailService(JavaMailSender mailSender) {
this.mailSender = mailSender;
}
/**
* Отправляет PDF отчет на указанный email адрес
*
* @param recipientEmail email получателя
* @param reportTitle название отчета
* @param authorName имя автора
* @param companyName название компании
* @param pdfBytes содержимое PDF файла
* @param filename имя файла
*/
public void sendReportByEmail(String recipientEmail, String reportTitle,
String authorName, String companyName,
byte[] pdfBytes, String filename) {
try {
MimeMessage message = mailSender.createMimeMessage();
MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8");
// Настройка получателя и отправителя
helper.setTo(recipientEmail);
helper.setFrom("noreply@konturai.kz"); // Можно настроить в конфигурации
// Тема письма
String subject = String.format("Аналитический отчет: %s",
reportTitle != null ? reportTitle : "Отчет");
helper.setSubject(subject);
// Текст письма
String emailBody = buildEmailBody(reportTitle, authorName, companyName);
helper.setText(emailBody, true); // true означает HTML формат
// Прикрепляем PDF файл
ByteArrayResource pdfResource = new ByteArrayResource(pdfBytes) {
@Override
public String getFilename() {
return filename;
}
};
helper.addAttachment(filename, pdfResource, "application/pdf");
// Отправляем письмо
mailSender.send(message);
System.out.println("Email с отчетом успешно отправлен на: " + recipientEmail);
} catch (MessagingException e) {
System.err.println("Ошибка при отправке email: " + e.getMessage());
e.printStackTrace();
}
}
/**
* Создает HTML содержимое письма
*/
private String buildEmailBody(String reportTitle, String authorName, String companyName) {
String currentDate = LocalDateTime.now().format(DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm"));
return String.format(
"""
<html>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
<h2 style="color: #2c3e50; border-bottom: 2px solid #3498db; padding-bottom: 10px;">
Аналитический отчет готов
</h2>
<p>Здравствуйте!</p>
<p>Ваш аналитический отчет успешно сгенерирован и готов к просмотру.</p>
<div style="background-color: #f8f9fa; padding: 15px; border-left: 4px solid #3498db; margin: 20px 0;">
<h3 style="margin-top: 0; color: #2c3e50;">Детали отчета:</h3>
<p><strong>Название:</strong> %s</p>
<p><strong>Автор:</strong> %s</p>
<p><strong>Компания:</strong> %s</p>
<p><strong>Дата создания:</strong> %s</p>
</div>
<p>PDF файл с отчетом прикреплен к данному письму. Вы можете скачать его и сохранить для дальнейшего использования.</p>
<p>Если у вас возникли вопросы или нужна дополнительная информация, пожалуйста, свяжитесь с нами.</p>
<hr style="margin: 30px 0; border: none; border-top: 1px solid #eee;">
<p style="font-size: 12px; color: #666;">
С уважением,<br>
Команда Konturai Analytics<br>
<em>Это письмо отправлено автоматически, пожалуйста, не отвечайте на него.</em>
</p>
</div>
</body>
</html>
""",
reportTitle != null ? reportTitle : "Не указано",
authorName != null ? authorName : "Не указан",
companyName != null ? companyName : "Не указана",
currentDate);
}
}
@@ -85,15 +85,18 @@ public class ReportGenerationService {
private final OllamaAnalyticsService ollamaAnalyticsService;
private final ReportHistoryRepository reportHistoryRepository;
private final MinIOService minIOService;
private final EmailService emailService;
public ReportGenerationService(MarketItemRepository marketItemRepository,
OllamaAnalyticsService ollamaAnalyticsService,
ReportHistoryRepository reportHistoryRepository,
MinIOService minIOService) {
MinIOService minIOService,
EmailService emailService) {
this.marketItemRepository = marketItemRepository;
this.ollamaAnalyticsService = ollamaAnalyticsService;
this.reportHistoryRepository = reportHistoryRepository;
this.minIOService = minIOService;
this.emailService = emailService;
}
public ReportBinary simpleGenerate(ReportGenerateRequest req) {
@@ -135,7 +138,8 @@ public class ReportGenerationService {
public void generateAsync(ReportGenerateRequest req) {
try {
generate(req);
// Report is automatically saved to database and MinIO in the generate() method
// Report is automatically saved to database, MinIO and sent by email in the
// generate() method
} catch (Exception e) {
// Log error but don't throw to avoid async method issues
System.err.println("Error generating report asynchronously: " + e.getMessage());
@@ -165,6 +169,24 @@ public class ReportGenerationService {
rb = new ReportBinary(filename, MediaType.APPLICATION_PDF, pdf);
}
persistReport(req, rb);
// Отправляем PDF на email, если указан email и формат PDF
if (req.getRecipientEmail() != null && !req.getRecipientEmail().trim().isEmpty()
&& "PDF".equalsIgnoreCase(req.getFormat())) {
try {
emailService.sendReportByEmail(
req.getRecipientEmail(),
req.getReportTitle(),
req.getAuthorName(),
req.getCompanyName(),
rb.getBytes(),
rb.getFilename());
} catch (Exception e) {
System.err.println("Ошибка при отправке email: " + e.getMessage());
// Не прерываем выполнение, если email не отправился
}
}
return rb;
}
@@ -0,0 +1,203 @@
package kz.konturai.parser.service;
import com.lowagie.text.*;
import com.lowagie.text.pdf.PdfWriter;
import kz.konturai.parser.dto.DeepResearchResponse;
import org.springframework.stereotype.Service;
import java.io.ByteArrayOutputStream;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
@Service
public class ResearchPdfService {
private static final Font TITLE_FONT = new Font(Font.HELVETICA, 18, Font.BOLD);
private static final Font HEADING_FONT = new Font(Font.HELVETICA, 14, Font.BOLD);
private static final Font SUBHEADING_FONT = new Font(Font.HELVETICA, 12, Font.BOLD);
private static final Font NORMAL_FONT = new Font(Font.HELVETICA, 10, Font.NORMAL);
private static final Font SMALL_FONT = new Font(Font.HELVETICA, 8, Font.NORMAL);
/**
* Генерирует PDF отчёт на основе ответа от deep-research API
*/
public byte[] generatePdfReport(String query, DeepResearchResponse researchResponse) {
try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
Document document = new Document(PageSize.A4);
PdfWriter.getInstance(document, outputStream);
document.open();
// Титульная страница
addTitlePage(document, query);
// Содержание
addTableOfContents(document);
// Основной отчёт
addMainReport(document, researchResponse);
// Список источников
addSources(document, researchResponse.getVisitedUrls());
document.close();
return outputStream.toByteArray();
} catch (Exception e) {
throw new RuntimeException("Failed to generate PDF report", e);
}
}
private void addTitlePage(Document document, String query) throws DocumentException {
// Заголовок
Paragraph title = new Paragraph(query, TITLE_FONT);
title.setAlignment(Element.ALIGN_CENTER);
title.setSpacingAfter(50);
document.add(title);
// Подзаголовок
Paragraph subtitle = new Paragraph("Исследовательский отчёт", HEADING_FONT);
subtitle.setAlignment(Element.ALIGN_CENTER);
subtitle.setSpacingAfter(30);
document.add(subtitle);
// Информация о дате
String currentDate = LocalDateTime.now().format(DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm"));
Paragraph date = new Paragraph("Дата создания: " + currentDate, NORMAL_FONT);
date.setAlignment(Element.ALIGN_CENTER);
date.setSpacingAfter(100);
document.add(date);
// Пустая строка для перехода на следующую страницу
document.add(new Paragraph(" "));
document.newPage();
}
private void addTableOfContents(Document document) throws DocumentException {
Paragraph tocTitle = new Paragraph("Содержание", HEADING_FONT);
tocTitle.setSpacingAfter(20);
document.add(tocTitle);
// Автоматически генерируемое содержание
String[] tocItems = {
"1. Введение",
"2. Основная часть",
"3. Заключение",
"4. Источники"
};
for (String item : tocItems) {
Paragraph tocItem = new Paragraph(item, NORMAL_FONT);
tocItem.setSpacingAfter(5);
document.add(tocItem);
}
document.add(new Paragraph(" "));
document.newPage();
}
private void addMainReport(Document document, DeepResearchResponse researchResponse) throws DocumentException {
String content = researchResponse.getMainContent();
if (content == null || content.trim().isEmpty()) {
content = "Отчёт не был сгенерирован.";
}
// Разбиваем контент на параграфы
String[] paragraphs = content.split("\n\n");
// Введение
Paragraph introTitle = new Paragraph("1. Введение", HEADING_FONT);
introTitle.setSpacingAfter(10);
document.add(introTitle);
Paragraph intro = new Paragraph(
"Данный отчёт представляет результаты исследования по теме: \"" +
researchResponse.getReport() != null ? "Полный отчёт"
: "Краткий ответ" + "\". " +
"Исследование проводилось с использованием автоматизированных инструментов анализа данных.",
NORMAL_FONT);
intro.setSpacingAfter(15);
document.add(intro);
// Основная часть
Paragraph mainTitle = new Paragraph("2. Основная часть", HEADING_FONT);
mainTitle.setSpacingAfter(10);
document.add(mainTitle);
// Добавляем основной контент
for (String paragraph : paragraphs) {
if (paragraph.trim().isEmpty())
continue;
// Проверяем, является ли строка заголовком (начинается с цифры или содержит
// ключевые слова)
if (isHeading(paragraph)) {
Paragraph heading = new Paragraph(paragraph, SUBHEADING_FONT);
heading.setSpacingAfter(5);
document.add(heading);
} else {
Paragraph para = new Paragraph(paragraph, NORMAL_FONT);
para.setSpacingAfter(8);
document.add(para);
}
}
// Заключение
Paragraph conclusionTitle = new Paragraph("3. Заключение", HEADING_FONT);
conclusionTitle.setSpacingAfter(10);
document.add(conclusionTitle);
Paragraph conclusion = new Paragraph(
"Исследование завершено. Полученные данные и выводы представлены в основной части отчёта. " +
"Для получения дополнительной информации рекомендуется обратиться к первоисточникам, " +
"указанным в разделе \"Источники\".",
NORMAL_FONT);
conclusion.setSpacingAfter(15);
document.add(conclusion);
}
private void addSources(Document document, List<String> visitedUrls) throws DocumentException {
Paragraph sourcesTitle = new Paragraph("4. Источники", HEADING_FONT);
sourcesTitle.setSpacingAfter(10);
document.add(sourcesTitle);
if (visitedUrls == null || visitedUrls.isEmpty()) {
Paragraph noSources = new Paragraph("Источники не указаны.", NORMAL_FONT);
document.add(noSources);
return;
}
for (int i = 0; i < visitedUrls.size(); i++) {
String url = visitedUrls.get(i);
Paragraph source = new Paragraph(
(i + 1) + ". " + url,
SMALL_FONT);
source.setSpacingAfter(3);
document.add(source);
}
}
private boolean isHeading(String text) {
if (text == null || text.trim().isEmpty())
return false;
String trimmed = text.trim();
// Проверяем, начинается ли с цифры и точки
if (trimmed.matches("^\\d+\\.\\s+.*"))
return true;
// Проверяем ключевые слова заголовков
String[] headingKeywords = {
"введение", "заключение", "выводы", "рекомендации", "анализ",
"результаты", "методология", "цель", "задачи", "основные", "ключевые"
};
String lowerText = trimmed.toLowerCase();
for (String keyword : headingKeywords) {
if (lowerText.startsWith(keyword))
return true;
}
return false;
}
}
+13
View File
@@ -50,3 +50,16 @@ logging.level.com.mongodb=WARN
ollama.host=http://185.35.223.45:11434
ollama.model=gemma3:1b
logging.level.kz.konturai.parser.service.OllamaAnalyticsService=INFO
# Email Configuration
spring.mail.host=smtp.gmail.com
spring.mail.port=587
spring.mail.username=your-email@gmail.com
spring.mail.password=your-app-password
spring.mail.properties.mail.smtp.auth=true
spring.mail.properties.mail.smtp.starttls.enable=true
spring.mail.properties.mail.smtp.starttls.required=true
# Deep Research API Configuration
deep-research.api.url=http://185.35.223.45:3051
deep-research.api.timeout=300000
@@ -0,0 +1,91 @@
package kz.konturai.parser.service;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.mail.javamail.JavaMailSender;
import jakarta.mail.MessagingException;
import jakarta.mail.internet.MimeMessage;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class EmailServiceTest {
@Mock
private JavaMailSender mailSender;
@Mock
private MimeMessage mimeMessage;
private EmailService emailService;
@BeforeEach
void setUp() {
emailService = new EmailService(mailSender);
}
@Test
void testSendReportByEmail() throws MessagingException {
// Arrange
String recipientEmail = "test@example.com";
String reportTitle = "Test Report";
String authorName = "Test Author";
String companyName = "Test Company";
byte[] pdfBytes = "test pdf content".getBytes();
String filename = "test-report.pdf";
when(mailSender.createMimeMessage()).thenReturn(mimeMessage);
// Act
emailService.sendReportByEmail(recipientEmail, reportTitle, authorName, companyName, pdfBytes, filename);
// Assert
verify(mailSender, times(1)).createMimeMessage();
verify(mailSender, times(1)).send(any(MimeMessage.class));
}
@Test
void testSendReportByEmailWithNullValues() throws MessagingException {
// Arrange
String recipientEmail = "test@example.com";
String reportTitle = null;
String authorName = null;
String companyName = null;
byte[] pdfBytes = "test pdf content".getBytes();
String filename = "test-report.pdf";
when(mailSender.createMimeMessage()).thenReturn(mimeMessage);
// Act
emailService.sendReportByEmail(recipientEmail, reportTitle, authorName, companyName, pdfBytes, filename);
// Assert
verify(mailSender, times(1)).createMimeMessage();
verify(mailSender, times(1)).send(any(MimeMessage.class));
}
@Test
void testSendReportByEmailWithMessagingException() throws MessagingException {
// Arrange
String recipientEmail = "test@example.com";
String reportTitle = "Test Report";
String authorName = "Test Author";
String companyName = "Test Company";
byte[] pdfBytes = "test pdf content".getBytes();
String filename = "test-report.pdf";
when(mailSender.createMimeMessage()).thenThrow(new MessagingException("SMTP error"));
// Act
emailService.sendReportByEmail(recipientEmail, reportTitle, authorName, companyName, pdfBytes, filename);
// Assert
verify(mailSender, times(1)).createMimeMessage();
verify(mailSender, never()).send(any(MimeMessage.class));
}
}
+56
View File
@@ -0,0 +1,56 @@
#!/bin/bash
# Тестовый скрипт для проверки нового API эндпоинта /api/parser/report
echo "Тестирование API эндпоинта для генерации исследовательских отчётов"
echo "=================================================================="
# URL API
API_URL="http://localhost:8080/api/parser/report"
# Тестовые данные
echo "Отправка запроса на генерацию отчёта..."
curl -X POST "$API_URL" \
-H "Content-Type: application/json" \
-d '{
"query": "Искусственный интеллект в здравоохранении",
"lang": "ru",
"depth": 3,
"breadth": 5,
"report_type": "report"
}' \
--output "research_report_test.pdf" \
--write-out "HTTP Status: %{http_code}\nResponse Time: %{time_total}s\n"
echo ""
echo "Проверка статуса ответа..."
if [ -f "research_report_test.pdf" ]; then
echo "✅ PDF файл успешно создан: research_report_test.pdf"
echo "Размер файла: $(ls -lh research_report_test.pdf | awk '{print $5}')"
else
echo "❌ PDF файл не был создан"
fi
echo ""
echo "Тестирование с минимальными параметрами..."
curl -X POST "$API_URL" \
-H "Content-Type: application/json" \
-d '{
"query": "Блокчейн технологии"
}' \
--output "research_report_minimal.pdf" \
--write-out "HTTP Status: %{http_code}\nResponse Time: %{time_total}s\n"
echo ""
echo "Тестирование с некорректными данными (должен вернуть 400)..."
curl -X POST "$API_URL" \
-H "Content-Type: application/json" \
-d '{
"query": "",
"depth": 10
}' \
--write-out "HTTP Status: %{http_code}\n"
echo ""
echo "Тестирование завершено!"
@@ -0,0 +1,84 @@
### **Техническое задание на доработку AI-агента для генерации PDF-отчётов**
**Дата:** 5 октября 2025 г.
**Проект:** Модификация существующего бэкенда для интеграции с сервисом `deep-research`.
#### **1. Общее описание**
Целью данной доработки является интеграция внешнего AI-сервиса `deep-research` (далее "Сервис Исследований") в существующий бэкенд. Необходимо модифицировать эндпоинт `/api/parser/report` таким образом, чтобы он принимал от пользователя тему для исследования, передавал её Сервису Исследований, получал в ответ сгенерированный текстовый отчёт и преобразовывал его в готовый для скачивания PDF-файл.
#### **2. Требования к реализации**
**2.1. Модификация эндпоинта `/api/parser/report`**
- **Метод:** `POST`
- **URL:** `/api/parser/report`
- **Тело запроса (`Request Body`):**
- Формат: `application/json`
- Поля:
- `query` (string, обязательное) - Тема для исследования.
- `lang` (string, опциональное, по умолчанию "ru") - Язык для итогового отчёта (например, "ru", "en").
- `depth` (integer, опциональное, по умолчанию 3) - Глубина исследования (целое число от 1 до 5).
- `breadth` (integer, опциональное, по умолчанию 5) - Широта исследования (целое число от 2 до 10).
- `report_type` (string, опциональное, по умолчанию "report") - Тип отчёта ("report" для полного отчёта, "answer" для краткого ответа).
- **Успешный ответ (`Success Response`):**
- **Код:** `200 OK`
- **Заголовки:**
- `Content-Type: application/pdf`
- `Content-Disposition: attachment; filename="research_report.pdf"` (имя файла можно генерировать динамически).
- **Тело ответа:** Сгенерированный PDF-файл.
- **Ответы с ошибками (`Error Responses`):**
- **Код:** `400 Bad Request` - Если в запросе отсутствуют обязательные поля или их формат некорректен.
- **Код:** `500 Internal Server Error` - Если произошла внутренняя ошибка на бэкенде или при вызове Сервиса Исследований.
- **Код:** `504 Gateway Timeout` - Если Сервис Исследований не отвечает в течение заданного времени.
**2.2. Логика работы агента**
Процесс должен следовать по шагам:
1. **Приём запроса:** Эндпоинт `/api/parser/report` получает `POST` запрос с параметрами от фронтенда.
2. **Валидация:** Бэкенд проверяет корректность полученных данных (например, что `query` не пустое, а `depth` — число в нужном диапазоне).
3. **Вызов Сервиса Исследований:**
- Бэкенд формирует и отправляет `POST` запрос на эндпоинт Сервиса Исследований, используя HTTP-клиент (например, `axios` для Node.js или `requests` для Python).
- **Целевой URL:** `http://185.35.223.45:3051/api/research`
- **Тело запроса:** JSON-объект, сформированный из параметров, полученных на шаге 1.
- Все API-ключи (`OPENAI_API_KEY`, `FIRECRAWL_API_KEY`) должны быть безопасно сохранены в переменных окружения бэкенда и использоваться Сервисом Исследований.
4. **Обработка ответа:**
- Бэкенд ожидает ответ от Сервиса Исследований в формате JSON.
- Из ответа извлекается сгенерированный полный текст отчёта (предположительно, из поля `report` или `answer`), а также список использованных URL (`visitedUrls`).
- В случае ошибки от Сервиса Исследований, ошибка логируется, и клиенту возвращается ответ с кодом `500`.
5. **Генерация PDF:**
- Полученный текст отчёта передаётся в модуль генерации PDF.
- Используется специализированная библиотека (например, `pdfkit` или `puppeteer` для Node.js; `ReportLab` для Python).
- PDF-файл формируется в соответствии с требованиями к форматированию (см. п. 2.3).
6. **Отправка PDF клиенту:** Бэкенд отправляет сгенерированный PDF-файл в теле ответа с соответствующими заголовками.
**2.3. Требования к PDF-отчёту**
Сгенерированный PDF-документ должен иметь профессиональный вид и чёткую структуру:
- **Титульная страница:** Название исследования (из поля `query`).
- **Содержание (Table of Contents):** Автоматически сгенерированное оглавление на основе заголовков в отчёте.
- **Тело отчёта:** Основной текст, полученный от Сервиса Исследований, с сохранением форматирования (заголовки, абзацы, списки).
- **Список источников:** В конце документа должен быть раздел "Источники" или "Библиография", содержащий список URL-адресов из поля `visitedUrls`.
#### **3. Технологический стек**
- **Бэкенд:** Реализация должна использовать текущий стек бэкенда (например, Node.js/Express, Python/Django/FastAPI и т.д.).
- **HTTP-клиент:** Для взаимодействия с Сервисом Исследований (например, `axios`).
- **Библиотека для генерации PDF:** На выбор разработчика, в зависимости от стека (например, `pdfkit`, `puppeteer`, `ReportLab`).
#### **4. Нефункциональные требования**
- **Асинхронность:** Процесс исследования может занимать несколько минут. Необходимо реализовать асинхронную обработку, чтобы избежать таймаута HTTP-запроса от клиента.
- **Рекомендация:** При получении запроса бэкенд может сразу возвращать `202 Accepted` с ID задачи. Фронтенд будет периодически опрашивать другой эндпоинт (`/api/parser/report/status/{id}`), чтобы проверить готовность отчёта и получить ссылку на скачивание. _(На первом этапе можно реализовать как долго выполняющийся запрос, но асинхронная модель является предпочтительной для будущего развития.)_
#### **5. Что не входит в задачи**
- Разработка фронтенд-части для взаимодействия с API.
- Реализация аутентификации и авторизации пользователей.
- Создание системы очередей для обработки нескольких запросов одновременно (может быть добавлено на следующем этапе).