ready
This commit is contained in:
@@ -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: [детали ошибки]
|
||||
```
|
||||
@@ -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`
|
||||
@@ -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` в ответе не пустое
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
- Реализация аутентификации и авторизации пользователей.
|
||||
- Создание системы очередей для обработки нескольких запросов одновременно (может быть добавлено на следующем этапе).
|
||||
Reference in New Issue
Block a user