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>
|
<artifactId>minio</artifactId>
|
||||||
<version>8.5.7</version>
|
<version>8.5.7</version>
|
||||||
</dependency>
|
</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>
|
</dependencies>
|
||||||
|
|
||||||
<build>
|
<build>
|
||||||
|
|||||||
@@ -3,9 +3,14 @@ package kz.konturai.parser.controller;
|
|||||||
import kz.konturai.parser.dto.ReportGenerateRequest;
|
import kz.konturai.parser.dto.ReportGenerateRequest;
|
||||||
import kz.konturai.parser.dto.ReportGenerationResponse;
|
import kz.konturai.parser.dto.ReportGenerationResponse;
|
||||||
import kz.konturai.parser.dto.ApiResponse;
|
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.model.ReportHistory;
|
||||||
import kz.konturai.parser.repository.ReportHistoryRepository;
|
import kz.konturai.parser.repository.ReportHistoryRepository;
|
||||||
import kz.konturai.parser.service.ReportGenerationService;
|
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 kz.konturai.parser.service.MinIOService;
|
||||||
import org.springframework.data.domain.Page;
|
import org.springframework.data.domain.Page;
|
||||||
import org.springframework.data.domain.PageRequest;
|
import org.springframework.data.domain.PageRequest;
|
||||||
@@ -27,84 +32,171 @@ import kz.konturai.parser.dto.PageResponse;
|
|||||||
@RequestMapping("/api/parser/report")
|
@RequestMapping("/api/parser/report")
|
||||||
public class ReportController {
|
public class ReportController {
|
||||||
|
|
||||||
private final ReportGenerationService reportGenerationService;
|
private final ReportGenerationService reportGenerationService;
|
||||||
private final ReportHistoryRepository reportHistoryRepository;
|
private final ReportHistoryRepository reportHistoryRepository;
|
||||||
private final MinIOService minIOService;
|
private final MinIOService minIOService;
|
||||||
|
private final DeepResearchService deepResearchService;
|
||||||
|
private final ResearchPdfService researchPdfService;
|
||||||
|
|
||||||
public ReportController(ReportGenerationService reportGenerationService,
|
public ReportController(ReportGenerationService reportGenerationService,
|
||||||
ReportHistoryRepository reportHistoryRepository,
|
ReportHistoryRepository reportHistoryRepository,
|
||||||
MinIOService minIOService) {
|
MinIOService minIOService,
|
||||||
this.reportGenerationService = reportGenerationService;
|
DeepResearchService deepResearchService,
|
||||||
this.reportHistoryRepository = reportHistoryRepository;
|
ResearchPdfService researchPdfService) {
|
||||||
this.minIOService = minIOService;
|
this.reportGenerationService = reportGenerationService;
|
||||||
}
|
this.reportHistoryRepository = reportHistoryRepository;
|
||||||
|
this.minIOService = minIOService;
|
||||||
@PostMapping("/generate")
|
this.deepResearchService = deepResearchService;
|
||||||
public ResponseEntity<ApiResponse<ReportGenerationResponse>> generate(
|
this.researchPdfService = researchPdfService;
|
||||||
@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();
|
|
||||||
}
|
}
|
||||||
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")
|
@PostMapping("/generate")
|
||||||
? MediaType
|
public ResponseEntity<ApiResponse<ReportGenerationResponse>> generate(
|
||||||
.parseMediaType("application/vnd.openxmlformats-officedocument.wordprocessingml.document")
|
@Validated @RequestBody ReportGenerateRequest request) {
|
||||||
: MediaType.APPLICATION_PDF;
|
// Generate unique task ID
|
||||||
return ResponseEntity.ok()
|
String taskId = UUID.randomUUID().toString();
|
||||||
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + rh.getFilename() + "\"")
|
|
||||||
.contentType(mt)
|
// Start async report generation
|
||||||
.body(bytes);
|
reportGenerationService.generateAsync(request);
|
||||||
} catch (Exception e) {
|
|
||||||
return ResponseEntity.internalServerError().build();
|
// 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 startDate;
|
||||||
private LocalDateTime endDate;
|
private LocalDateTime endDate;
|
||||||
private String format; // PDF or DOCX
|
private String format; // PDF or DOCX
|
||||||
|
private String recipientEmail; // Email для отправки отчета
|
||||||
|
|
||||||
public String getReportTitle() {
|
public String getReportTitle() {
|
||||||
return reportTitle;
|
return reportTitle;
|
||||||
@@ -58,4 +59,12 @@ public class ReportGenerateRequest {
|
|||||||
public void setFormat(String format) {
|
public void setFormat(String format) {
|
||||||
this.format = 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 OllamaAnalyticsService ollamaAnalyticsService;
|
||||||
private final ReportHistoryRepository reportHistoryRepository;
|
private final ReportHistoryRepository reportHistoryRepository;
|
||||||
private final MinIOService minIOService;
|
private final MinIOService minIOService;
|
||||||
|
private final EmailService emailService;
|
||||||
|
|
||||||
public ReportGenerationService(MarketItemRepository marketItemRepository,
|
public ReportGenerationService(MarketItemRepository marketItemRepository,
|
||||||
OllamaAnalyticsService ollamaAnalyticsService,
|
OllamaAnalyticsService ollamaAnalyticsService,
|
||||||
ReportHistoryRepository reportHistoryRepository,
|
ReportHistoryRepository reportHistoryRepository,
|
||||||
MinIOService minIOService) {
|
MinIOService minIOService,
|
||||||
|
EmailService emailService) {
|
||||||
this.marketItemRepository = marketItemRepository;
|
this.marketItemRepository = marketItemRepository;
|
||||||
this.ollamaAnalyticsService = ollamaAnalyticsService;
|
this.ollamaAnalyticsService = ollamaAnalyticsService;
|
||||||
this.reportHistoryRepository = reportHistoryRepository;
|
this.reportHistoryRepository = reportHistoryRepository;
|
||||||
this.minIOService = minIOService;
|
this.minIOService = minIOService;
|
||||||
|
this.emailService = emailService;
|
||||||
}
|
}
|
||||||
|
|
||||||
public ReportBinary simpleGenerate(ReportGenerateRequest req) {
|
public ReportBinary simpleGenerate(ReportGenerateRequest req) {
|
||||||
@@ -135,7 +138,8 @@ public class ReportGenerationService {
|
|||||||
public void generateAsync(ReportGenerateRequest req) {
|
public void generateAsync(ReportGenerateRequest req) {
|
||||||
try {
|
try {
|
||||||
generate(req);
|
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) {
|
} catch (Exception e) {
|
||||||
// Log error but don't throw to avoid async method issues
|
// Log error but don't throw to avoid async method issues
|
||||||
System.err.println("Error generating report asynchronously: " + e.getMessage());
|
System.err.println("Error generating report asynchronously: " + e.getMessage());
|
||||||
@@ -165,6 +169,24 @@ public class ReportGenerationService {
|
|||||||
rb = new ReportBinary(filename, MediaType.APPLICATION_PDF, pdf);
|
rb = new ReportBinary(filename, MediaType.APPLICATION_PDF, pdf);
|
||||||
}
|
}
|
||||||
persistReport(req, rb);
|
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;
|
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.host=http://185.35.223.45:11434
|
||||||
ollama.model=gemma3:1b
|
ollama.model=gemma3:1b
|
||||||
logging.level.kz.konturai.parser.service.OllamaAnalyticsService=INFO
|
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