diff --git a/EMAIL_INTEGRATION_GUIDE.md b/EMAIL_INTEGRATION_GUIDE.md new file mode 100644 index 0000000..d9bc38a --- /dev/null +++ b/EMAIL_INTEGRATION_GUIDE.md @@ -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: [детали ошибки] +``` diff --git a/IMPLEMENTATION_SUMMARY.md b/IMPLEMENTATION_SUMMARY.md new file mode 100644 index 0000000..fff766d --- /dev/null +++ b/IMPLEMENTATION_SUMMARY.md @@ -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` diff --git a/RESEARCH_API_GUIDE.md b/RESEARCH_API_GUIDE.md new file mode 100644 index 0000000..aa6d9b5 --- /dev/null +++ b/RESEARCH_API_GUIDE.md @@ -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` в ответе не пустое diff --git a/pom.xml b/pom.xml index 5fab26a..25fd0ab 100644 --- a/pom.xml +++ b/pom.xml @@ -101,6 +101,18 @@ minio 8.5.7 + + + + org.springframework.boot + spring-boot-starter-mail + + + + + com.fasterxml.jackson.core + jackson-databind + diff --git a/src/main/java/kz/konturai/parser/controller/ReportController.java b/src/main/java/kz/konturai/parser/controller/ReportController.java index b06b334..0bc10f8 100644 --- a/src/main/java/kz/konturai/parser/controller/ReportController.java +++ b/src/main/java/kz/konturai/parser/controller/ReportController.java @@ -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> 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> 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 res = reportHistoryRepository.findAll(pageable); - PageResponse dto = new PageResponse<>( - res.getContent(), - res.getNumber(), - res.getSize(), - res.getTotalElements(), - res.getTotalPages(), - sort); - return ResponseEntity.ok(dto); - } - - @GetMapping("/history/{id}") - public ResponseEntity download(@PathVariable("id") String id) { - Optional 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> 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 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> 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 res = reportHistoryRepository.findAll(pageable); + PageResponse dto = new PageResponse<>( + res.getContent(), + res.getNumber(), + res.getSize(), + res.getTotalElements(), + res.getTotalPages(), + sort); + return ResponseEntity.ok(dto); + } + + @GetMapping("/history/{id}") + public ResponseEntity download(@PathVariable("id") String id) { + Optional 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(); + } } - } } diff --git a/src/main/java/kz/konturai/parser/dto/DeepResearchRequest.java b/src/main/java/kz/konturai/parser/dto/DeepResearchRequest.java new file mode 100644 index 0000000..34cbb77 --- /dev/null +++ b/src/main/java/kz/konturai/parser/dto/DeepResearchRequest.java @@ -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; + } +} diff --git a/src/main/java/kz/konturai/parser/dto/DeepResearchResponse.java b/src/main/java/kz/konturai/parser/dto/DeepResearchResponse.java new file mode 100644 index 0000000..127b088 --- /dev/null +++ b/src/main/java/kz/konturai/parser/dto/DeepResearchResponse.java @@ -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 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 getVisitedUrls() { + return visitedUrls; + } + + public void setVisitedUrls(List 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 : ""; + } +} diff --git a/src/main/java/kz/konturai/parser/dto/ReportGenerateRequest.java b/src/main/java/kz/konturai/parser/dto/ReportGenerateRequest.java index bbf54f0..a4805a1 100644 --- a/src/main/java/kz/konturai/parser/dto/ReportGenerateRequest.java +++ b/src/main/java/kz/konturai/parser/dto/ReportGenerateRequest.java @@ -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; + } } diff --git a/src/main/java/kz/konturai/parser/dto/ResearchReportRequest.java b/src/main/java/kz/konturai/parser/dto/ResearchReportRequest.java new file mode 100644 index 0000000..3ed0645 --- /dev/null +++ b/src/main/java/kz/konturai/parser/dto/ResearchReportRequest.java @@ -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; + } +} diff --git a/src/main/java/kz/konturai/parser/service/DeepResearchService.java b/src/main/java/kz/konturai/parser/service/DeepResearchService.java new file mode 100644 index 0000000..91a6bb9 --- /dev/null +++ b/src/main/java/kz/konturai/parser/service/DeepResearchService.java @@ -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 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)); + } +} diff --git a/src/main/java/kz/konturai/parser/service/EmailService.java b/src/main/java/kz/konturai/parser/service/EmailService.java new file mode 100644 index 0000000..333e5e0 --- /dev/null +++ b/src/main/java/kz/konturai/parser/service/EmailService.java @@ -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( + """ + + +
+

+ Аналитический отчет готов +

+ +

Здравствуйте!

+ +

Ваш аналитический отчет успешно сгенерирован и готов к просмотру.

+ +
+

Детали отчета:

+

Название: %s

+

Автор: %s

+

Компания: %s

+

Дата создания: %s

+
+ +

PDF файл с отчетом прикреплен к данному письму. Вы можете скачать его и сохранить для дальнейшего использования.

+ +

Если у вас возникли вопросы или нужна дополнительная информация, пожалуйста, свяжитесь с нами.

+ +
+ +

+ С уважением,
+ Команда Konturai Analytics
+ Это письмо отправлено автоматически, пожалуйста, не отвечайте на него. +

+
+ + + """, + reportTitle != null ? reportTitle : "Не указано", + authorName != null ? authorName : "Не указан", + companyName != null ? companyName : "Не указана", + currentDate); + } +} diff --git a/src/main/java/kz/konturai/parser/service/ReportGenerationService.java b/src/main/java/kz/konturai/parser/service/ReportGenerationService.java index 89195d0..ad4e8f4 100644 --- a/src/main/java/kz/konturai/parser/service/ReportGenerationService.java +++ b/src/main/java/kz/konturai/parser/service/ReportGenerationService.java @@ -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; } diff --git a/src/main/java/kz/konturai/parser/service/ResearchPdfService.java b/src/main/java/kz/konturai/parser/service/ResearchPdfService.java new file mode 100644 index 0000000..5f09098 --- /dev/null +++ b/src/main/java/kz/konturai/parser/service/ResearchPdfService.java @@ -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 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; + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 55d16df..31aa5bf 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -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 diff --git a/src/test/java/kz/konturai/parser/service/EmailServiceTest.java b/src/test/java/kz/konturai/parser/service/EmailServiceTest.java new file mode 100644 index 0000000..26aff84 --- /dev/null +++ b/src/test/java/kz/konturai/parser/service/EmailServiceTest.java @@ -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)); + } +} diff --git a/test_research_api.sh b/test_research_api.sh new file mode 100644 index 0000000..8c4a646 --- /dev/null +++ b/test_research_api.sh @@ -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 "Тестирование завершено!" diff --git a/Техническое задание на доработку AI-агента для генерации PDF-отчётов.md b/Техническое задание на доработку AI-агента для генерации PDF-отчётов.md new file mode 100644 index 0000000..9864a6f --- /dev/null +++ b/Техническое задание на доработку AI-агента для генерации PDF-отчётов.md @@ -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. +- Реализация аутентификации и авторизации пользователей. +- Создание системы очередей для обработки нескольких запросов одновременно (может быть добавлено на следующем этапе).