diff --git a/N8N_INTEGRATION.md b/N8N_INTEGRATION.md new file mode 100644 index 0000000..a9361b5 --- /dev/null +++ b/N8N_INTEGRATION.md @@ -0,0 +1,292 @@ +# 🔌 Интеграция n8n с React Frontend + +## 📡 Redis Pub/Sub для real-time событий + +### Публикация события из n8n (HTTP Request Node) + +**POST** `http://147.45.146.17:8100/api/v1/events/{task_id}` + +```json +{ + "status": "processing|ocr_started|ocr_completed|ai_started|completed|error", + "message": "Описание для пользователя", + "data": { + "chars": 1500, + "confidence": 0.95, + "document_type": "policy", + "extracted_data": {...} + } +} +``` + +**Примеры:** + +1. **Начало обработки:** +```json +POST /api/v1/events/abc-123-def +{ + "status": "processing", + "message": "Начата обработка файла", + "data": { + "filename": "Policy_123.pdf" + } +} +``` + +2. **OCR завершён:** +```json +POST /api/v1/events/abc-123-def +{ + "status": "ocr_completed", + "message": "Распознано 1500 символов", + "data": { + "chars": 1500, + "ocr_text_preview": "ЕВРОИНС ПОЛИС E1000-..." + } +} +``` + +3. **AI анализ:** +```json +POST /api/v1/events/abc-123-def +{ + "status": "ai_started", + "message": "Запущен AI анализ документа", + "data": {} +} +``` + +4. **Завершено:** +```json +POST /api/v1/events/abc-123-def +{ + "status": "completed", + "message": "Обработка завершена", + "data": { + "document_type": "policy", + "is_valid": true, + "confidence": 0.95, + "extracted_data": { + "voucher": "E1000-302545808", + "holder_name": "ROMANOVA ANASTASIIA", + "insured_from": "22.09.2025", + "insured_to": "30.09.2025" + } + } +} +``` + +5. **Ошибка:** +```json +POST /api/v1/events/abc-123-def +{ + "status": "error", + "message": "Ошибка обработки: файл повреждён", + "data": { + "error_code": "OCR_FAILED" + } +} +``` + +--- + +## 🎯 Вебхуки для n8n + +### 1. Проверка полиса (MySQL) + +**POST** `/webhook/check-policy` + +**Request:** +```json +{ + "policy_number": "E1000-302545808", + "inn": "123456789012" +} +``` + +**Response:** +```json +{ + "found": true, + "policy": { + "voucher": "E1000-302545808", + "holder_name": "ROMANOVA ANASTASIIA", + "status": "active" + } +} +``` + +--- + +### 2. Загрузка файла в S3 + +**POST** `/webhook/upload-file` + +**Request (multipart/form-data):** +- `file`: File +- `folder`: "policies" | "documents" | "tickets" + +**Response:** +```json +{ + "success": true, + "task_id": "abc-123-def", + "s3_url": "https://s3.twcstorage.ru/bucket/policies/file.pdf", + "message": "Файл загружен, обработка началась" +} +``` + +**n8n Flow:** +1. Загрузить в S3 +2. Сгенерировать `task_id` (UUID) +3. Положить задачу в RabbitMQ (`erv_ocr_processing`) +4. Вернуть `task_id` + +--- + +### 3. OCR Worker (RabbitMQ Trigger) + +**n8n Workflow:** + +``` +RabbitMQ Trigger (erv_ocr_processing) + ↓ +Скачать файл из S3 + ↓ +POST /api/v1/events/{task_id} + status: "processing" + ↓ +HTTP Request → OCR API + POST http://147.45.146.17:8001/analyze-file + ↓ +POST /api/v1/events/{task_id} + status: "ocr_completed" + data: {chars: ..., ocr_text: "..."} + ↓ +HTTP Request → Gemini Vision (OpenRouter) + ↓ +POST /api/v1/events/{task_id} + status: "completed" + data: {document_type, is_valid, extracted_data} + ↓ +Сохранить результат в Redis + key: "ocr_result:{task_id}" + ttl: 3600 +``` + +--- + +### 4. Получение результата OCR + +**GET** `/webhook/ocr-result/{task_id}` + +**Response:** +```json +{ + "success": true, + "result": { + "document_type": "policy", + "is_valid": true, + "confidence": 0.95, + "ocr_text": "...", + "extracted_data": {...} + } +} +``` + +**n8n:** Читает из Redis `ocr_result:{task_id}` + +--- + +### 5. Создание заявки (финал) + +**POST** `/webhook/create-claim` + +**Request:** +```json +{ + "voucher": "E1000-302545808", + "email": "user@example.com", + "phone": "+79001234567", + "incident": { + "type": "flight_delay", + "date": "2025-10-25", + "flight_number": "SU123", + "description": "Задержка более 3 часов" + }, + "payment": { + "method": "sbp", + "bank": "sberbank" + }, + "documents": [ + "https://s3.../ticket1.pdf", + "https://s3.../boarding_pass.pdf" + ] +} +``` + +**Response:** +```json +{ + "success": true, + "claim_id": "CLM-2025-001", + "crm_id": "12345", + "message": "Заявка успешно создана" +} +``` + +**n8n Flow:** +1. Проверить все данные +2. Создать запись в PostgreSQL +3. Отправить в Vtiger CRM +4. Отправить email подтверждение +5. Вернуть claim_id + +--- + +## 📊 Draft (автосохранение) + +**POST** `/webhook/draft/save` + +```json +{ + "session_id": "sess-abc-123", + "step": 1, + "form_data": {...} +} +``` + +**GET** `/webhook/draft/stats` + +Возвращает статистику: сколько людей бросили на каждом шаге. + +--- + +## 🔗 Redis Connection + +**Host:** `crm.clientright.ru` +**Port:** `6379` +**Password:** `CRM_Redis_Pass_2025_Secure!` +**DB:** `0` + +**Channels:** +- `ocr_events:{task_id}` - события обработки + +--- + +## 📝 Примечания + +1. **task_id** - генерируется как UUID в n8n +2. **Redis TTL** - результаты хранятся 1 час +3. **RabbitMQ** - `185.197.75.249:5672` (admin/tyejvtej) +4. **S3** - TWC Storage, креды в .env + +--- + +**Готово для n8n! 🚀** + + + + + + diff --git a/N8N_PDF_COMPRESS.md b/N8N_PDF_COMPRESS.md new file mode 100644 index 0000000..10dc522 --- /dev/null +++ b/N8N_PDF_COMPRESS.md @@ -0,0 +1,256 @@ +# 🗜️ PDF Compression в n8n + +## 📋 Проблема +Пользователь загружает PDF 5-10 MB → долгая обработка OCR + +## ✅ Решение: 2-уровневая система + +--- + +## 🎯 Уровень 1: Frontend (React) + +**Что делаем:** +- JPG/PNG → сжатие до 2MB → конвертация в PDF +- PDF < 5MB → пропускаем +- PDF > 10MB → **отклоняем** с сообщением + +**Код:** `frontend/src/utils/pdfConverter.ts` ✅ УЖЕ ГОТОВО + +--- + +## 🎯 Уровень 2: Backend (n8n) + +### Workflow для сжатия PDF > 5MB + +``` +Webhook (file upload) + ↓ +IF Node: file_size > 5 MB? + ├─ FALSE → S3 Upload (оригинал) + └─ TRUE → Python Code Node (compress) + ↓ + S3 Upload (compressed) +``` + +--- + +## 🐍 Python Code Node - PDF Compression + +### Установка библиотеки в n8n + +```bash +# В контейнере n8n +docker exec -it sh +apk add --no-cache python3 py3-pip +pip3 install pypdf +``` + +### Code Node конфигурация + +**Language:** Python +**Mode:** Run Once for All Items + +**Code:** +```python +import io +from pypdf import PdfReader, PdfWriter + +# Получаем binary data из предыдущей ноды +input_data = items[0].binary['data'] +pdf_bytes = input_data + +# Читаем PDF +reader = PdfReader(io.BytesIO(pdf_bytes)) +writer = PdfWriter() + +# Копируем страницы с оптимизацией +for page in reader.pages: + # Удаляем неиспользуемые объекты + page.compress_content_streams() + writer.add_page(page) + +# Применяем сжатие +writer.compress_identical_objects() +writer.remove_duplication() + +# Сжимаем изображения (если есть) +for page in writer.pages: + for img in page.images: + img.replace(img.image, quality=70) + +# Выводим в bytes +output = io.BytesIO() +writer.write(output) +compressed_bytes = output.getvalue() + +# Логируем результат +original_size = len(pdf_bytes) / (1024 * 1024) +compressed_size = len(compressed_bytes) / (1024 * 1024) +compression_ratio = ((original_size - compressed_size) / original_size) * 100 + +print(f"✅ Compressed: {original_size:.2f}MB → {compressed_size:.2f}MB ({compression_ratio:.1f}% reduction)") + +# Возвращаем binary data +return { + 'binary': { + 'data': compressed_bytes + }, + 'json': { + 'original_size_mb': round(original_size, 2), + 'compressed_size_mb': round(compressed_size, 2), + 'compression_ratio': round(compression_ratio, 1), + 'success': True + } +} +``` + +--- + +## 🔧 Вариант 2: Execute Command (Ghostscript) + +**Требует:** `ghostscript` установлен в системе + +### Execute Command Node: + +```bash +#!/bin/bash + +INPUT="/tmp/input_{{ $json.file_id }}.pdf" +OUTPUT="/tmp/output_{{ $json.file_id }}.pdf" + +# Сохраняем binary в файл +echo "{{ $binary.data }}" | base64 -d > "$INPUT" + +# Сжимаем через Ghostscript +gs -sDEVICE=pdfwrite \ + -dCompatibilityLevel=1.4 \ + -dPDFSETTINGS=/ebook \ + -dNOPAUSE \ + -dQUIET \ + -dBATCH \ + -sOutputFile="$OUTPUT" \ + "$INPUT" + +# Выводим compressed PDF +cat "$OUTPUT" | base64 + +# Cleanup +rm -f "$INPUT" "$OUTPUT" +``` + +**Параметры `-dPDFSETTINGS`:** +- `/screen` - 72 DPI (минимальное качество, максимальное сжатие) +- `/ebook` - 150 DPI ⭐ **рекомендуется** +- `/printer` - 300 DPI +- `/prepress` - 300 DPI (максимальное качество) + +--- + +## 🔄 Полный Workflow + +### 1. Webhook (File Upload) + +**Input:** +```json +{ + "claim_id": "CLM-2025-10-26-ABC123", + "file_type": "policy_scan", + "filename": "policy.pdf", + "voucher": "E1000-302372730", + "session_id": "sess-xyz-456" +} +``` + +**Binary Data:** `data` (PDF file) + +--- + +### 2. IF Node: Check File Size + +**Condition:** +``` +{{ $binary.data.length }} > 5242880 +``` +(5MB = 5 * 1024 * 1024 bytes) + +--- + +### 3a. FALSE → Direct Upload + +**S3 Upload Node** → PostgreSQL + +--- + +### 3b. TRUE → Compress First + +``` +Python Code (compress) + ↓ +Set Binary Data + ↓ +S3 Upload (compressed) + ↓ +PostgreSQL (update file_size) +``` + +--- + +## 📊 Результаты сжатия + +| Метод | Скорость | Сжатие | Качество | +|-------|----------|--------|----------| +| **pypdf** | Быстро | 30-50% | Хорошее ⭐ | +| **Ghostscript /ebook** | Средне | 50-70% | Среднее | +| **Ghostscript /screen** | Средне | 70-85% | Низкое | +| **Frontend (jspdf)** | Моментально | 60-80% | Хорошее ✅ | + +--- + +## 🎯 Итоговая стратегия + +``` +📱 Пользователь загружает файл + ↓ +🔍 Frontend проверка: + ├─ JPG/PNG → compress + convert → PDF (✅ готово) + ├─ PDF < 5MB → отправить как есть + ├─ PDF 5-10MB → отправить (n8n сожмёт) + └─ PDF > 10MB → ❌ отклонить + +🚀 n8n workflow: + ├─ file_size < 5MB → S3 + OCR + └─ file_size > 5MB → Python compress → S3 + OCR +``` + +--- + +## 🧪 Тестирование + +### curl пример: + +```bash +# Создаём большой PDF для теста +curl -o large.pdf https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf + +# Отправляем в n8n +curl -X POST \ + -F "claim_id=CLM-TEST-001" \ + -F "file_type=policy_scan" \ + -F "fileInput=@large.pdf" \ + -F "voucher=TEST-123" \ + -F "session_id=sess-test" \ + https://n8n.clientright.pro/webhook/7e2abc64-eaca-4671-86e4-12786700fe95 +``` + +--- + +## ✅ Готово! + +**Frontend:** ✅ Ограничение 10MB + предупреждение +**n8n:** ⏳ Нужно добавить Python Code Node + +**Следующий шаг:** Добавить Python Code Node в workflow для файлов > 5MB + + + + diff --git a/N8N_SQL_QUERIES.md b/N8N_SQL_QUERIES.md new file mode 100644 index 0000000..1f67097 --- /dev/null +++ b/N8N_SQL_QUERIES.md @@ -0,0 +1,434 @@ +# 📝 SQL запросы для n8n вебхуков + +## PostgreSQL Connection: +- **Host:** `147.45.189.234` +- **Port:** `5432` +- **Database:** `default_db` +- **User:** `gen_user` +- **Password:** `2~~9_^kVsU?2\S` + +--- + +## 1️⃣ Создание заявки (при генерации claim_id) + +**Вебхук:** `POST /webhook/create-claim` + +**Input:** +```json +{ + "claim_id": "CLM-2025-10-25-A3F7G2", + "voucher": "E1000-302372730", + "client_phone": "", + "client_email": "", + "session_id": "sess-abc-123" +} +``` + +**SQL (PostgreSQL Node):** +```sql +INSERT INTO claims ( + claim_number, + policy_number, + client_phone, + client_email, + status, + insurance_type, + source, + form_data, + created_at +) VALUES ( + '{{ $json.body.claim_id }}', + '{{ $json.body.voucher }}', + '{{ $json.body.client_phone || "" }}', + '{{ $json.body.client_email || "" }}', + 'draft', + 'erv_travel', + 'web_form', + '{{ JSON.stringify($json.body) }}', + NOW() +) +ON CONFLICT (claim_number) DO UPDATE SET + updated_at = NOW(), + form_data = EXCLUDED.form_data +RETURNING id, claim_number, created_at; +``` + +**Response:** +```json +{ + "success": true, + "claim_id": "CLM-2025-10-25-A3F7G2", + "db_id": "uuid-from-db", + "created_at": "2025-10-25T10:00:00Z" +} +``` + +--- + +## 2️⃣ Сохранение файла в claim_files + +**После S3 Upload в том же workflow!** + +**SQL (PostgreSQL Node после S3):** +```sql +INSERT INTO claim_files ( + claim_id, + file_name, + file_path, + file_size, + mime_type, + file_type, + s3_bucket, + s3_key, + s3_url, + ocr_status, + created_at +) +SELECT + c.id, + '{{ $json.file.original_name }}', + '{{ $json.s3.key }}', + {{ $json.file.size || 0 }}, + '{{ $json.file.mime_type }}', + '{{ $json.claim.file_type }}', + 'f9825c87-4e3558f6-f9b6-405c-ad3d-d1535c49b61c', + '{{ $json.s3.key }}', + '{{ $('Upload a file1').item.json.Location }}', + 'pending', + NOW() +FROM claims c +WHERE c.claim_number = '{{ $json.claim.claim_id }}' +RETURNING id as file_id, s3_url, ocr_status; +``` + +**Response (добавь в Respond):** +```json +{ + "success": true, + "claim_id": "CLM-2025-10-25-A3F7G2", + "file": { + "file_id": "uuid-from-db", + "type": "policy_scan", + "url": "https://s3.../policy_scan.pdf", + "s3_key": "files/erv/ticket/CLM-xxx/policy_scan.pdf", + "ocr_status": "pending" + } +} +``` + +--- + +## 3️⃣ Обновление OCR результата + +**OCR Workflow (после обработки):** + +**SQL:** +```sql +UPDATE claim_files +SET + ocr_status = 'completed', + ocr_text = '{{ $json.ocr_text }}', + processed_at = NOW() +WHERE id = '{{ $json.file_id }}' +RETURNING id, ocr_status; +``` + +--- + +## 4️⃣ Обновление Vision AI результата + +**SQL:** +```sql +UPDATE claim_files +SET + ai_extracted_data = '{{ JSON.stringify($json.ai_analysis) }}', + processed_at = NOW() +WHERE id = '{{ $json.file_id }}' +RETURNING id, ai_extracted_data; +``` + +**Пример ai_extracted_data:** +```json +{ + "document_type": "policy", + "is_valid": true, + "confidence": 0.95, + "voucher": "E1000-302372730", + "holder_name": "IVANOV IVAN", + "insured_from": "01.11.2025", + "insured_to": "30.11.2025" +} +``` + +--- + +## 5️⃣ Получить все файлы заявки + +**Вебхук:** `GET /webhook/get-claim-files/{claim_id}` + +**SQL:** +```sql +SELECT + cf.id, + cf.file_name, + cf.file_type, + cf.s3_url, + cf.file_size, + cf.ocr_status, + cf.ocr_text, + cf.ai_extracted_data, + cf.created_at, + cf.processed_at +FROM claim_files cf +JOIN claims c ON c.id = cf.claim_id +WHERE c.claim_number = '{{ $parameter.claim_id }}' +ORDER BY cf.created_at; +``` + +**Response:** +```json +{ + "success": true, + "claim_id": "CLM-2025-10-25-A3F7G2", + "files": [ + { + "file_id": "...", + "file_type": "policy_scan", + "s3_url": "...", + "ocr_status": "completed", + "ocr_text": "ЕВРОИНС...", + "ai_extracted_data": {...} + } + ] +} +``` + +--- + +## 6️⃣ Финальная отправка заявки + +**SQL (обновляем статус):** +```sql +UPDATE claims +SET + status = 'submitted', + client_phone = '{{ $json.phone }}', + client_email = '{{ $json.email }}', + form_data = '{{ JSON.stringify($json.form_data) }}', + submitted_at = NOW(), + updated_at = NOW() +WHERE claim_number = '{{ $json.claim_id }}' +RETURNING id, claim_number, status, submitted_at; +``` + +--- + +## 7️⃣ Публикация результатов OCR/Vision в Redis + +**После OCR/Vision обработки - отправляем результат в React через Redis Pub/Sub** + +### Webhook для публикации: + +**POST** `http://147.45.189.234:8000/events/{claim_id}` + +**Headers:** +``` +Content-Type: application/json +``` + +**Body (n8n Code Node):** +```json +{ + "event_type": "ocr_completed", + "status": "success", + "data": { + "file_id": "{{ $json.file_id }}", + "file_type": "policy_scan", + "is_valid_document": true, + "document_type": "ERV Travel Insurance Policy", + "ocr_text": "E1000-302372730", + "confidence": 0.95, + "ai_analysis": { + "is_policy": true, + "contains_policy_number": true, + "is_nsfw": false, + "warnings": [] + } + }, + "message": "✅ Распознан полис страхования ERV", + "timestamp": "{{ new Date().toISOString() }}" +} +``` + +--- + +### Code Node для валидации документа: + +**После OCR + Vision:** + +```javascript +// Получаем результаты OCR и Vision +const ocrData = $json.ocr_result; // Из предыдущей ноды +const visionData = $json.vision_result; + +// Валидация документа +const validation = { + is_valid_document: false, + document_type: 'unknown', + confidence: 0, + warnings: [] +}; + +// 1. Проверка на NSFW +if (visionData.nsfw === true || visionData.nsfw_score > 0.7) { + validation.warnings.push('Неподходящее содержимое изображения'); + validation.is_valid_document = false; + validation.document_type = 'inappropriate_content'; +} + +// 2. Проверка текста OCR на наличие номера полиса +const policyNumberRegex = /[A-Z]\d{4}-\d{9}/; +const hasPolicyNumber = policyNumberRegex.test(ocrData.ocr_text); + +if (hasPolicyNumber) { + validation.is_valid_document = true; + validation.document_type = 'ERV Travel Insurance Policy'; + validation.confidence = 0.9; +} else { + validation.warnings.push('Номер полиса не найден'); +} + +// 3. Анализ Vision описания +const visionText = visionData.content?.toLowerCase() || ''; +const insuranceKeywords = ['страхов', 'insurance', 'полис', 'policy', 'erv']; +const hasInsuranceKeywords = insuranceKeywords.some(kw => visionText.includes(kw)); + +if (hasInsuranceKeywords) { + validation.confidence += 0.05; +} else { + validation.warnings.push('Документ не похож на страховой полис'); + validation.is_valid_document = false; +} + +// 4. Формируем результат для публикации в Redis +const result = { + file_id: $json.file_id, + claim_id: $json.claim_id, + event_type: 'ocr_completed', + status: validation.is_valid_document ? 'success' : 'error', + data: { + file_id: $json.file_id, + file_type: $json.file_type, + is_valid_document: validation.is_valid_document, + document_type: validation.document_type, + ocr_text: ocrData.ocr_text, + confidence: validation.confidence, + ai_analysis: { + is_policy: validation.is_valid_document, + contains_policy_number: hasPolicyNumber, + is_nsfw: visionData.nsfw, + nsfw_score: visionData.nsfw_score, + warnings: validation.warnings + } + }, + message: validation.is_valid_document + ? '✅ Распознан полис страхования ERV' + : `❌ ${validation.warnings.join(', ')}`, + timestamp: new Date().toISOString() +}; + +return result; +``` + +--- + +### HTTP Request Node (публикация в Redis): + +**Method:** `POST` +**URL:** `http://147.45.189.234:8000/events/{{ $json.claim_id }}` +**Headers:** +```json +{ + "Content-Type": "application/json" +} +``` + +**Body:** +```json +{{ $json }} +``` + +--- + +### React подписка на события: + +**Frontend код:** + +```typescript +useEffect(() => { + if (!claimId) return; + + // Подключаемся к SSE + const eventSource = new EventSource( + `http://147.45.189.234:8000/events/${claimId}` + ); + + eventSource.onmessage = (event) => { + const data = JSON.parse(event.data); + + if (data.event_type === 'ocr_completed') { + setUploadProgress(''); // Убираем крутилку + + if (data.status === 'success' && data.data.is_valid_document) { + message.success(data.message); + // ✅ Полис распознан - можно продолжать + } else { + message.error(data.message); + // ❌ Это не полис - показываем предупреждение + Modal.error({ + title: 'Документ не распознан', + content: data.data.ai_analysis.warnings.join('\n') + }); + } + } + }; + + return () => eventSource.close(); +}, [claimId]); +``` + +--- + +### Полный workflow в n8n: + +``` +Webhook (file upload) + ↓ +S3 Upload + ↓ +PostgreSQL (INSERT claim_files) + ↓ +OCR Service (HTTP Request) + ↓ +Vision Service (HTTP Request) + ↓ +Code Node (валидация документа) + ↓ +IF Node: is_valid_document? + ├─ TRUE → PostgreSQL UPDATE (ocr_status = 'valid') + │ ↓ + │ HTTP POST → /events/{claim_id} (Redis Pub/Sub) + │ ↓ + │ Respond to Webhook: {success: true} + │ + └─ FALSE → PostgreSQL UPDATE (ocr_status = 'invalid') + ↓ + HTTP POST → /events/{claim_id} (Redis Pub/Sub) + ↓ + Respond to Webhook: {success: true, warning: true} +``` + +--- + +**Готово! Теперь делаем вебхуки в n8n?** 🚀 + diff --git a/N8N_STIRLING_COMPRESS.md b/N8N_STIRLING_COMPRESS.md new file mode 100644 index 0000000..8a3ccc9 --- /dev/null +++ b/N8N_STIRLING_COMPRESS.md @@ -0,0 +1,145 @@ +# 🗜️ PDF Compression для n8n + +## ⚠️ UPDATE: Stirling API недоступен! + +**Альтернатива:** Используем **Ghostscript** или **Python pypdf** + +--- + +## 🐍 Вариант 1: Python Code Node (РЕКОМЕНДУЕТСЯ) + +### 1️⃣ Базовая настройка + +**Method:** `POST` +**URL:** `https://stirling.klientprav.tech/api/v1/general/compress-pdf` + +--- + +## 2️⃣ Authentication + +- **Type:** `Header Auth` +- **Name:** `X-API-Key` +- **Value:** `HTYgGMCZ64rlzoRbbmg6IeutXzJHEdVpKV1` + +--- + +## 3️⃣ Body + +**Content Type:** `Multipart-Form Data` + +### Fields: + +| Property Name | Type | Value | +|--------------|------|-------| +| `fileInput` | Binary Data | `{{ $binary.data }}` | +| `optimizeLevel` | String | `3` | +| `expectedOutputSize` | String | `2` | + +**Схема:** +```json +[ + { + "name": "fileInput", + "data": "{{ $binary.data }}" + }, + { + "name": "optimizeLevel", + "data": "3" + }, + { + "name": "expectedOutputSize", + "data": "2" + } +] +``` + +--- + +## 4️⃣ Send Binary Data + +**Include Binary Data:** `Yes` +**Binary Property Name:** `data` + +--- + +## 📥 Response + +Stirling вернёт **сжатый PDF** в формате: + +### Success: +- **Status:** `200 OK` +- **Body:** Binary PDF file +- **Headers:** + ``` + Content-Type: application/pdf + Content-Disposition: attachment; filename="compressed.pdf" + ``` + +### Error: +```json +{ + "message": "Error description", + "status": 400 +} +``` + +--- + +## 🔗 Использование в workflow + +### Полная цепочка: + +``` +Webhook (получили PDF) + ↓ +IF Node: file_size > 5 MB? + ├─ TRUE → HTTP Request (Stirling Compress) + │ ↓ + │ Binary Data (сжатый PDF) + │ ↓ + └─ FALSE → Binary Data (оригинал) + ↓ + S3 Upload (оба варианта) + ↓ + PostgreSQL (запись пути) +``` + +--- + +## 🧪 Curl пример для теста + +```bash +curl -X POST \ + -H "X-API-Key: HTYgGMCZ64rlzoRbbmg6IeutXzJHEdVpKV1" \ + -F "fileInput=@/path/to/file.pdf" \ + -F "optimizeLevel=3" \ + -F "expectedOutputSize=2" \ + https://stirling.klientprav.tech/api/v1/general/compress-pdf \ + --output compressed.pdf +``` + +--- + +## ⚙️ Параметры сжатия + +- **optimizeLevel:** + - `1` = минимальное сжатие (быстро) + - `2` = среднее сжатие (баланс) + - `3` = максимальное сжатие (медленно, но эффективно) ⭐ + +- **expectedOutputSize:** + - Целевой размер в MB (опционально) + - Например: `2` = максимум 2MB + +--- + +## 📝 Примечания + +⚠️ **Важно:** +1. Stirling работает только с **PDF** +2. JPEG/PNG сначала конвертируются в PDF на **frontend** +3. В n8n приходит уже **PDF** +4. Если файл > 5MB → **сжимаем в Stirling** +5. Если файл ≤ 5MB → **пропускаем Stirling** + +--- diff --git a/backend/app/api/events.py b/backend/app/api/events.py new file mode 100644 index 0000000..450349a --- /dev/null +++ b/backend/app/api/events.py @@ -0,0 +1,131 @@ +""" +SSE (Server-Sent Events) для real-time обновлений через Redis Pub/Sub +""" +import asyncio +import json +from fastapi import APIRouter, Body +from fastapi.responses import StreamingResponse +from pydantic import BaseModel +from typing import Dict, Any +from app.services.redis_service import redis_service +import logging + +logger = logging.getLogger(__name__) + +router = APIRouter() + + +class EventPublish(BaseModel): + """Модель для публикации события""" + event_type: str = "ocr_completed" + status: str + message: str + data: Dict[str, Any] = {} + timestamp: str = None + + +@router.post("/events/{task_id}") +async def publish_event(task_id: str, event: EventPublish): + """ + Публикация события в Redis канал + + Используется n8n для отправки событий (OCR, AI и т.д.) + + Args: + task_id: ID задачи + event: Данные события + + Returns: + Статус публикации + """ + try: + channel = f"ocr_events:{task_id}" + event_data = { + "event_type": event.event_type, + "status": event.status, + "message": event.message, + "data": event.data, + "timestamp": event.timestamp + } + + # Публикуем в Redis + event_json = json.dumps(event_data, ensure_ascii=False) + await redis_service.publish(channel, event_json) + + logger.info(f"📢 Event published to {channel}: {event.status}") + + return { + "success": True, + "channel": channel, + "event": event_data + } + + except Exception as e: + logger.error(f"❌ Failed to publish event: {e}") + return { + "success": False, + "error": str(e) + } + + +@router.get("/events/{task_id}") +async def stream_events(task_id: str): + """ + SSE стрим событий обработки OCR + + Args: + task_id: ID задачи + + Returns: + StreamingResponse с событиями + """ + + async def event_generator(): + """Генератор событий из Redis Pub/Sub""" + channel = f"ocr_events:{task_id}" + + # Подписываемся на канал Redis + pubsub = redis_service.redis.pubsub() + await pubsub.subscribe(channel) + + logger.info(f"📡 Client subscribed to {channel}") + + # Отправляем начальное событие + yield f"data: {json.dumps({'status': 'connected', 'message': 'Подключено к событиям'})}\n\n" + + try: + # Слушаем события + while True: + message = await pubsub.get_message(ignore_subscribe_messages=True, timeout=30.0) + + if message and message['type'] == 'message': + event_data = message['data'].decode('utf-8') + + # Отправляем событие клиенту + yield f"data: {event_data}\n\n" + + # Если обработка завершена - закрываем соединение + event = json.loads(event_data) + if event.get('status') in ['completed', 'error']: + logger.info(f"✅ Task {task_id} finished, closing SSE") + break + + # Пинг каждые 30 сек чтобы соединение не закрылось + await asyncio.sleep(0.1) + + except asyncio.CancelledError: + logger.info(f"❌ Client disconnected from {channel}") + finally: + await pubsub.unsubscribe(channel) + await pubsub.close() + + return StreamingResponse( + event_generator(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no" # Отключаем буферизацию nginx + } + ) + diff --git a/backend/app/main.py b/backend/app/main.py index 4fdfd3f..0d8c471 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -12,7 +12,7 @@ from .services.redis_service import redis_service from .services.rabbitmq_service import rabbitmq_service from .services.policy_service import policy_service from .services.s3_service import s3_service -from .api import sms, claims, policy, upload, draft +from .api import sms, claims, policy, upload, draft, events # Настройка логирования logging.basicConfig( @@ -98,6 +98,7 @@ app.include_router(claims.router) app.include_router(policy.router) app.include_router(upload.router) app.include_router(draft.router) +app.include_router(events.router, prefix="/api/v1") @app.get("/") diff --git a/backend/app/services/ocr_service.py b/backend/app/services/ocr_service.py index dfd7c07..ea8f618 100644 --- a/backend/app/services/ocr_service.py +++ b/backend/app/services/ocr_service.py @@ -6,6 +6,7 @@ import logging from typing import Optional, Dict, Any from ..config import settings import json +from .s3_service import s3_service logger = logging.getLogger(__name__) @@ -39,25 +40,104 @@ class OCRService: } try: - # Шаг 1: OCR распознавание текста + # Шаг 0: Загружаем файл в S3 и получаем presigned URL + logger.info(f"📤 Uploading file to S3: {filename}") + + # Определяем content_type + content_type = "image/jpeg" + if filename.lower().endswith('.pdf'): + content_type = "application/pdf" + elif filename.lower().endswith('.png'): + content_type = "image/png" + elif filename.lower().endswith(('.heic', '.heif')): + content_type = "image/heic" + + # Загружаем в S3 + s3_url = await s3_service.upload_file( + file_content=file_content, + filename=filename, + content_type=content_type, + folder="ocr_temp" + ) + + if not s3_url: + logger.error("❌ Failed to upload file to S3") + return result + + # Используем простой публичный URL + # Файлы в ocr_temp/ загружаются с ACL=public-read + ocr_file_url = s3_url # Уже публичный URL! + + logger.info(f"✅ File uploaded to S3, using public URL for OCR") + + # Шаг 1: OCR распознавание текста через URL logger.info(f"🔍 Starting OCR for: {filename}") - async with httpx.AsyncClient(timeout=60.0) as client: - files = {"file": (filename, file_content, "image/jpeg")} + # Определяем file_type по расширению (OCR API требует строку!) + file_ext = filename.lower().split('.')[-1] + file_type_map = { + 'pdf': 'pdf', + 'jpg': 'jpeg', + 'jpeg': 'jpeg', + 'png': 'png', + 'heic': 'heic', + 'heif': 'heic', + 'docx': 'docx', + 'doc': 'doc' + } + file_type = file_type_map.get(file_ext, 'pdf') # По умолчанию pdf + + logger.info(f"📄 File type detected: {file_type}") + + async with httpx.AsyncClient(timeout=90.0) as client: + # OCR API ожидает JSON с file_url response = await client.post( f"{self.ocr_url}/analyze-file", - files=files + json={ + "file_url": ocr_file_url, # Публичный URL + "file_name": filename, + "file_type": file_type # ✅ Теперь строка, не None! + } ) if response.status_code == 200: ocr_result = response.json() - ocr_text = ocr_result.get("text", "") + + # OCR API возвращает массив: [{text: "", pages_data: [...]}] + ocr_text = "" + + if isinstance(ocr_result, list) and len(ocr_result) > 0: + data = ocr_result[0] + + # Пробуем извлечь текст из pages_data + if "pages_data" in data and len(data["pages_data"]) > 0: + # Собираем текст со всех страниц + texts = [] + for page in data["pages_data"]: + page_text = page.get("ocr_text", "") + if page_text: + texts.append(page_text) + ocr_text = "\n\n".join(texts) + + # Если нет pages_data, пробуем text или full_text + if not ocr_text: + ocr_text = data.get("text", "") or data.get("full_text", "") + + elif isinstance(ocr_result, dict): + # Старый формат (на всякий случай) + ocr_text = ocr_result.get("text", "") or ocr_result.get("full_text", "") + result["ocr_text"] = ocr_text logger.info(f"📄 OCR completed: {len(ocr_text)} chars") - logger.debug(f"OCR Text preview: {ocr_text[:200]}...") + if ocr_text: + logger.info(f"OCR Text preview: {ocr_text[:200]}...") + else: + logger.warning("⚠️ OCR returned empty text!") + logger.debug(f"OCR response structure: {list(ocr_result.keys()) if isinstance(ocr_result, dict) else type(ocr_result)}") else: logger.error(f"❌ OCR failed: {response.status_code}") + logger.error(f"Response: {response.text[:500]}") return result # Шаг 2: AI анализ - что это за документ? diff --git a/backend/app/services/redis_service.py b/backend/app/services/redis_service.py index 71a2db7..e25ebab 100644 --- a/backend/app/services/redis_service.py +++ b/backend/app/services/redis_service.py @@ -51,6 +51,13 @@ class RedisService: else: await self.client.set(full_key, value) + async def publish(self, channel: str, message: str): + """Публикация сообщения в канал Redis Pub/Sub""" + try: + await self.client.publish(channel, message) + except Exception as e: + logger.error(f"❌ Redis publish error: {e}") + async def delete(self, key: str) -> bool: """Удалить ключ""" full_key = f"{settings.redis_prefix}{key}" diff --git a/backend/app/services/s3_service.py b/backend/app/services/s3_service.py index 170a214..7855042 100644 --- a/backend/app/services/s3_service.py +++ b/backend/app/services/s3_service.py @@ -64,18 +64,22 @@ class S3Service: unique_id = str(uuid.uuid4())[:8] safe_filename = f"{folder}/{timestamp}_{unique_id}_{filename}" - # Загружаем файл + # Загружаем файл с публичным доступом (для OCR) + # ВРЕМЕННОЕ РЕШЕНИЕ: делаем файлы публичными пока presigned URL не работает + acl = 'public-read' if folder == 'ocr_temp' else 'private' + self.client.put_object( Bucket=self.bucket, Key=safe_filename, Body=file_content, - ContentType=content_type + ContentType=content_type, + ACL=acl # Делаем ocr_temp файлы публичными ) # Генерируем URL file_url = f"{settings.s3_endpoint}/{self.bucket}/{safe_filename}" - logger.info(f"✅ File uploaded to S3: {safe_filename}") + logger.info(f"✅ File uploaded to S3: {safe_filename} (ACL: {acl})") return file_url except Exception as e: @@ -97,6 +101,51 @@ class S3Service: except Exception as e: logger.error(f"❌ S3 delete error: {e}") return False + + def generate_presigned_url(self, file_key: str, expiration: int = 3600) -> Optional[str]: + """ + Генерация временного публичного URL для файла + + Args: + file_key: Ключ файла в S3 (путь) + expiration: Время жизни URL в секундах (по умолчанию 1 час) + + Returns: + Presigned URL или None при ошибке + """ + if not self.client: + self.connect() + + try: + # Для Timeweb Cloud Storage нужно использовать ClientMethod вместо обычного метода + # И добавить HttpMethod явно + url = self.client.generate_presigned_url( + ClientMethod='get_object', + Params={ + 'Bucket': self.bucket, + 'Key': file_key + }, + ExpiresIn=expiration, + HttpMethod='GET' + ) + logger.info(f"✅ Presigned URL generated for: {file_key} (expires in {expiration}s)") + return url + except Exception as e: + logger.error(f"❌ Presigned URL generation error: {e}") + return None + + def get_public_url(self, file_key: str) -> str: + """ + Простой публичный URL (без подписи) + ВНИМАНИЕ: Работает только если bucket публичный! + + Args: + file_key: Ключ файла в S3 + + Returns: + Публичный URL + """ + return f"{settings.s3_endpoint}/{self.bucket}/{file_key}" # Глобальный экземпляр diff --git a/backend/app/workers/ocr_worker.py b/backend/app/workers/ocr_worker.py new file mode 100644 index 0000000..86af1b6 --- /dev/null +++ b/backend/app/workers/ocr_worker.py @@ -0,0 +1,158 @@ +""" +OCR Worker - обработка файлов в фоне через RabbitMQ + Redis Pub/Sub +""" +import asyncio +import json +import logging +from typing import Dict, Any +from aio_pika import connect_robust, IncomingMessage +from app.config import settings +from app.services.ocr_service import ocr_service +from app.services.redis_service import redis_service + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +class OCRWorker: + """Worker для обработки OCR задач в фоне""" + + def __init__(self): + self.connection = None + self.channel = None + self.queue_name = "erv_ocr_processing" + + async def connect(self): + """Подключение к RabbitMQ""" + self.connection = await connect_robust(settings.rabbitmq_url) + self.channel = await self.connection.channel() + await self.channel.set_qos(prefetch_count=1) # По одной задаче + + self.queue = await self.channel.declare_queue( + self.queue_name, + durable=True + ) + + logger.info(f"✅ Worker connected to RabbitMQ: {self.queue_name}") + + async def publish_event(self, task_id: str, event: Dict[str, Any]): + """ + Публикация события в Redis для real-time обновлений + + Args: + task_id: ID задачи + event: Данные события + """ + channel = f"ocr_events:{task_id}" + event_json = json.dumps(event, ensure_ascii=False) + + try: + await redis_service.publish(channel, event_json) + logger.info(f"📢 Event published to {channel}: {event['status']}") + except Exception as e: + logger.error(f"❌ Failed to publish event: {e}") + + async def process_task(self, message: IncomingMessage): + """ + Обработка задачи OCR + + Args: + message: Сообщение из RabbitMQ + """ + async with message.process(): + try: + # Парсим задачу + task = json.loads(message.body.decode()) + task_id = task["task_id"] + file_content = bytes.fromhex(task["file_content_hex"]) + filename = task["filename"] + + logger.info(f"🔄 Processing task {task_id}: {filename}") + + # Событие: начало обработки + await self.publish_event(task_id, { + "status": "processing", + "message": "Начата обработка файла", + "filename": filename + }) + + # Шаг 1: OCR обработка + await self.publish_event(task_id, { + "status": "ocr_started", + "message": "Запущено распознавание текста" + }) + + result = await ocr_service.process_document(file_content, filename) + + # Событие: OCR завершён + await self.publish_event(task_id, { + "status": "ocr_completed", + "message": f"Распознано {len(result['ocr_text'])} символов", + "chars": len(result['ocr_text']) + }) + + # Шаг 2: AI анализ (если есть текст) + if result['ocr_text']: + await self.publish_event(task_id, { + "status": "ai_started", + "message": "Запущен AI анализ документа" + }) + + # Событие: всё готово + await self.publish_event(task_id, { + "status": "completed", + "message": "Обработка завершена", + "result": { + "document_type": result["document_type"], + "is_valid": result["is_valid"], + "confidence": result["confidence"], + "extracted_data": result["extracted_data"], + "ocr_text_length": len(result["ocr_text"]) + } + }) + + # Сохраняем результат в Redis (TTL 1 час) + cache_key = f"ocr_result:{task_id}" + await redis_service.set_json(cache_key, result, ttl=3600) + + logger.info(f"✅ Task {task_id} completed successfully") + + except Exception as e: + logger.error(f"❌ Task processing error: {e}") + + # Событие: ошибка + await self.publish_event(task_id, { + "status": "error", + "message": f"Ошибка обработки: {str(e)}" + }) + + async def start(self): + """Запуск worker""" + await self.connect() + + logger.info(f"🚀 OCR Worker started, waiting for tasks...") + + # Слушаем очередь + await self.queue.consume(self.process_task) + + # Держим worker живым + try: + await asyncio.Future() + except KeyboardInterrupt: + logger.info("👋 Worker stopped") + + +async def main(): + """Точка входа""" + worker = OCRWorker() + await worker.start() + + +if __name__ == "__main__": + asyncio.run(main()) + + + + + + diff --git a/backend/requirements.txt b/backend/requirements.txt index a98d0cf..d6efb9d 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -22,7 +22,6 @@ aiofiles==24.1.0 # S3 boto3==1.35.56 -aioboto3==13.2.0 # Validation pydantic==2.10.0 diff --git a/frontend/package.json b/frontend/package.json index c7dae92..698db4e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -25,7 +25,9 @@ "imask": "^7.6.1", "react-dropzone": "^14.3.5", "socket.io-client": "^4.8.1", - "serve": "^14.2.1" + "serve": "^14.2.1", + "jspdf": "^2.5.2", + "browser-image-compression": "^2.0.2" }, "devDependencies": { "@types/react": "^18.3.11", diff --git a/frontend/src/components/form/Step1Policy.tsx b/frontend/src/components/form/Step1Policy.tsx index 6192ca7..d2ecf1c 100644 --- a/frontend/src/components/form/Step1Policy.tsx +++ b/frontend/src/components/form/Step1Policy.tsx @@ -1,7 +1,8 @@ -import { useState } from 'react'; -import { Form, Input, Button, message, Upload, Progress } from 'antd'; -import { FileProtectOutlined, UploadOutlined, LoadingOutlined } from '@ant-design/icons'; +import { useState, useEffect, useRef } from 'react'; +import { Form, Input, Button, message, Upload, Spin, Alert, Modal } from 'antd'; +import { FileProtectOutlined, UploadOutlined, LoadingOutlined, CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons'; import type { UploadFile } from 'antd/es/upload/interface'; +import { convertToPDF } from '../../utils/pdfConverter'; interface Props { formData: any; @@ -56,7 +57,76 @@ export default function Step1Policy({ formData, updateFormData, onNext, addDebug const [policyNotFound, setPolicyNotFound] = useState(false); const [fileList, setFileList] = useState([]); const [uploading, setUploading] = useState(false); - const [ocrProgress, setOcrProgress] = useState(''); + const [uploadProgress, setUploadProgress] = useState(''); + const [ocrResult, setOcrResult] = useState(null); + const eventSourceRef = useRef(null); + + // SSE подключение для получения результатов OCR/Vision + useEffect(() => { + const claimId = formData.claim_id; + if (!claimId || !uploading) return; + + // Подключаемся к SSE для получения результатов OCR + const eventSource = new EventSource(`http://147.45.189.234:8000/events/${claimId}`); + eventSourceRef.current = eventSource; + + eventSource.onmessage = (event) => { + try { + const data = JSON.parse(event.data); + console.log('📨 SSE event received:', data); + + if (data.event_type === 'ocr_completed') { + setUploadProgress(''); // Убираем крутилку + setOcrResult(data); + + if (data.status === 'success' && data.data?.is_valid_document) { + // ✅ Полис распознан успешно + message.success(data.message || '✅ Полис успешно распознан!'); + addDebugEvent?.('ocr', 'success', data.message, data.data); + } else { + // ❌ Документ не распознан или это не полис + const warnings = data.data?.ai_analysis?.warnings || ['Документ не распознан']; + + Modal.error({ + title: '❌ Документ не распознан', + content: ( +
+

{data.message}

+ {warnings.length > 0 && ( +
    + {warnings.map((w: string, i: number) => ( +
  • {w}
  • + ))} +
+ )} +

+ Пожалуйста, загрузите скан страхового полиса ERV. +

+
+ ), + }); + + addDebugEvent?.('ocr', 'error', data.message, data.data); + setFileList([]); // Очищаем список файлов + } + } + } catch (error) { + console.error('SSE parse error:', error); + } + }; + + eventSource.onerror = (error) => { + console.error('SSE connection error:', error); + eventSource.close(); + }; + + return () => { + if (eventSourceRef.current) { + eventSourceRef.current.close(); + eventSourceRef.current = null; + } + }; + }, [formData.claim_id, uploading]); // Обработчик изменения поля полиса с автозаменой и маской const handleVoucherChange = (e: React.ChangeEvent) => { @@ -81,32 +151,40 @@ export default function Step1Policy({ formData, updateFormData, onNext, addDebug addDebugEvent?.('policy_check', 'pending', `Проверяю полис: ${values.voucher}`, { voucher: values.voucher }); - // Проверка полиса через API - const response = await fetch('http://147.45.146.17:8100/api/v1/policy/check', { + // Проверка полиса через n8n вебхук + создание записи в БД + const response = await fetch('https://n8n.clientright.pro/webhook/9eb7bc5b-645f-477d-a5d8-5a346260a265', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - voucher: values.voucher, - email: 'temp@check.com', // Email не требуется на этом шаге + claim_id: formData.claim_id, // Передаём claim_id для создания записи + policy_number: values.voucher, + session_id: sessionStorage.getItem('session_id') || 'unknown' }), }); const result = await response.json(); if (response.ok) { - if (result.found) { + // Новый формат ответа от n8n: {claim: {...}, policy: {...}} + const policyFound = result.policy?.found === 1 || result.policy?.found === true; + + if (policyFound) { // Полис найден - переходим дальше - addDebugEvent?.('policy_check', 'success', `✅ Полис найден в MySQL БД (33,963 полисов)`, { + addDebugEvent?.('policy_check', 'success', `✅ Полис найден в MySQL БД`, { found: true, + claim: result.claim, + policy: result.policy, voucher: values.voucher }); - message.success('Полис найден в базе данных'); + message.success(`Полис найден: ${result.policy.voucher}. Застрахованных: ${result.policy.count} чел.`); updateFormData(values); onNext(); } else { // Полис НЕ найден - показываем загрузку скана - addDebugEvent?.('policy_check', 'warning', `⚠️ Полис не найден → требуется загрузка скана`, { + addDebugEvent?.('policy_check', 'warning', `▲ Полис не найден → требуется загрузка скана`, { found: false, + claim: result.claim, + message: result.policy?.message || 'Полис не найден', voucher: values.voucher }); message.warning('Полис не найден в базе. Загрузите скан полиса'); @@ -131,59 +209,8 @@ export default function Step1Policy({ formData, updateFormData, onNext, addDebug setFileList(newFileList); }; - // Polling для получения OCR результатов - const pollOcrResults = async (fileIds: string[]) => { - if (fileIds.length === 0) return; - - const maxAttempts = 10; - const interval = 3000; // 3 секунды - - for (let attempt = 0; attempt < maxAttempts; attempt++) { - await new Promise(resolve => setTimeout(resolve, interval)); - - setOcrProgress(`🔍 Обработка OCR... (${attempt + 1}/${maxAttempts})`); - - for (const fileId of fileIds) { - try { - const response = await fetch(`http://147.45.146.17:8100/api/v1/upload/ocr-result/${fileId}`); - const result = await response.json(); - - if (result.found && result.ocr_result) { - const ocr = result.ocr_result; - - addDebugEvent?.('ocr', 'success', `📄 OCR завершен: ${ocr.ocr_text?.length || 0} символов`, { - text: ocr.ocr_text?.substring(0, 300) - }); - - if (ocr.ai_analysis || ocr.document_type) { - const isGarbage = ocr.document_type === 'garbage'; - - addDebugEvent?.( - 'ai_analysis', - isGarbage ? 'warning' : 'success', - isGarbage - ? `🗑️ ШЛЯПА DETECTED! (пользователю не говорим)` - : `🤖 Gemini Vision: ${ocr.document_type}, confidence: ${(ocr.confidence * 100).toFixed(0)}%`, - { - document_type: ocr.document_type, - is_valid: ocr.is_valid, - confidence: ocr.confidence, - extracted_data: ocr.extracted_data - } - ); - - setOcrProgress(`✅ OCR завершен: ${ocr.document_type}`); - return; // Готово - } - } - } catch (error) { - console.error('OCR polling error:', error); - } - } - } - - setOcrProgress('⏱️ OCR обрабатывается в фоне...'); - }; + // OCR теперь обрабатывается в n8n (через RabbitMQ + Redis Pub/Sub) + // Polling не нужен! const handleSubmitWithScan = async () => { if (fileList.length === 0) { @@ -198,27 +225,81 @@ export default function Step1Policy({ formData, updateFormData, onNext, addDebug try { setUploading(true); + setUploadProgress('📤 Подготавливаем документы...'); const values = await form.validateFields(['voucher']); - addDebugEvent?.('upload', 'pending', `📤 Загружаю ${fileList.length} файл(ов) в S3...`, { + addDebugEvent?.('upload', 'pending', `📤 Загружаю ${fileList.length} файл(ов) в S3 через n8n...`, { count: fileList.length }); - // Загружаем файлы в S3 с OCR проверкой - const formData = new FormData(); - fileList.forEach((file: any) => { - if (file.originFileObj) { - formData.append('files', file.originFileObj); + // Генерируем claim_id если его нет + const claimId = formData.claim_id || `CLM-${new Date().toISOString().split('T')[0]}-${Math.random().toString(36).substr(2, 6).toUpperCase()}`; + + // Загружаем каждый файл через n8n вебхук + const uploadedFiles = []; + + for (let i = 0; i < fileList.length; i++) { + const file = fileList[i]; + if (!file.originFileObj) continue; + + // 🔄 Конвертируем в PDF перед отправкой + let pdfFile: File; + try { + setUploadProgress(`🔄 Конвертируем ${file.name} в PDF...`); + addDebugEvent?.('convert', 'pending', `🔄 Конвертирую ${file.name} в PDF...`, { + original_size: `${(file.originFileObj.size / 1024 / 1024).toFixed(2)} MB`, + original_type: file.originFileObj.type + }); + + pdfFile = await convertToPDF(file.originFileObj); + + addDebugEvent?.('convert', 'success', `✅ PDF готов: ${pdfFile.name}`, { + pdf_size: `${(pdfFile.size / 1024 / 1024).toFixed(2)} MB` + }); + } catch (error: any) { + addDebugEvent?.('convert', 'error', `❌ Ошибка конвертации: ${error.message}`); + message.error('Ошибка конвертации файла'); + continue; } - }); - formData.append('folder', 'policies'); - const uploadResponse = await fetch('http://147.45.146.17:8100/api/v1/upload/files?folder=policies', { - method: 'POST', - body: formData, - }); + const uploadFormData = new FormData(); + uploadFormData.append('claim_id', claimId); + uploadFormData.append('file_type', 'policy_scan'); + uploadFormData.append('filename', pdfFile.name); // PDF имя + uploadFormData.append('voucher', values.voucher); + uploadFormData.append('session_id', sessionStorage.getItem('session_id') || 'unknown'); + uploadFormData.append('upload_timestamp', new Date().toISOString()); + uploadFormData.append('file', pdfFile); // PDF файл! - const uploadResult = await uploadResponse.json(); + setUploadProgress(`📡 Загружаем ${pdfFile.name} в облако...`); + const uploadResponse = await fetch('https://n8n.clientright.pro/webhook/7e2abc64-eaca-4671-86e4-12786700fe95', { + method: 'POST', + body: uploadFormData, + }); + + setUploadProgress(`🔍 Распознаём текст и проверяем документ...`); + const uploadResult = await uploadResponse.json(); + + // Логируем ответ от n8n для отладки + console.log('n8n upload response:', uploadResult); + + const resultData = Array.isArray(uploadResult) ? uploadResult[0] : uploadResult; + if (resultData?.success) { + uploadedFiles.push({ + filename: file.name, + success: true + }); + } else { + console.error('Upload failed for file:', file.name, 'Response:', uploadResult); + } + } + + const uploadResult = { + success: uploadedFiles.length > 0, + uploaded_count: uploadedFiles.length, + total_count: fileList.length, + files: uploadedFiles + }; if (uploadResult.success) { addDebugEvent?.('upload', 'success', `✅ Загружено в S3: ${uploadResult.uploaded_count}/${uploadResult.total_count}`, { @@ -226,27 +307,15 @@ export default function Step1Policy({ formData, updateFormData, onNext, addDebug files: uploadResult.files }); - // Проверяем OCR результаты - if (uploadResult.files && uploadResult.files.length > 0) { - const fileIds = uploadResult.files - .filter((f: any) => f.file_id) - .map((f: any) => f.file_id); - - const firstFile = uploadResult.files[0]; - - addDebugEvent?.('ocr', 'pending', `🔍 Запущен OCR для: ${firstFile.filename}`, { - file_id: firstFile.file_id, - filename: firstFile.filename - }); - - setOcrProgress('🔄 Запуск OCR...'); - - // Запускаем polling в фоне (не блокируем переход) - pollOcrResults(fileIds); - } + // OCR запустится автоматически в n8n workflow (параллельно) + addDebugEvent?.('ocr', 'pending', `🔄 OCR запущен в фоне через n8n`, { + claim_id: claimId, + message: 'Обработка продолжается асинхронно' + }); updateFormData({ - ...values, + ...values, + claim_id: claimId, policyScanUploaded: true, policyScanFiles: uploadResult.files, policyValidationWarning: '' // Silent validation @@ -263,6 +332,7 @@ export default function Step1Policy({ formData, updateFormData, onNext, addDebug console.error(error); } finally { setUploading(false); + setUploadProgress(''); } }; @@ -336,57 +406,55 @@ export default function Step1Policy({ formData, updateFormData, onNext, addDebug fileList={fileList} onChange={handleUploadChange} beforeUpload={(file) => { + // Проверка размера (макс 15MB для сырого файла) const isLt15M = file.size / 1024 / 1024 < 15; if (!isLt15M) { message.error(`${file.name}: файл больше 15MB`); return Upload.LIST_IGNORE; } - if (fileList.length >= 10) { - message.error('Максимум 10 файлов'); + + // Проверка формата + const validTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp', 'application/pdf']; + const validExtensions = /\.(jpg|jpeg|png|pdf|heic|heif|webp)$/i; + + if (!validTypes.includes(file.type) && !validExtensions.test(file.name)) { + message.error(`${file.name}: неподдерживаемый формат. Используйте JPG, PNG, PDF, HEIC или WEBP`); return Upload.LIST_IGNORE; } - return false; + + return false; // Не загружать автоматически }} - accept="image/*,.pdf,.heic,.heif" - multiple - maxCount={10} + accept="image/*,.pdf,.heic,.heif,.webp" + multiple={false} + maxCount={1} showUploadList={{ showPreviewIcon: true, showRemoveIcon: true, }} > -
- Загружено: {fileList.length}/10 файлов + Поддерживаются: JPG, PNG, HEIC, WEBP, PDF (макс 15MB) + {fileList.length > 0 && ( + + (автоконвертация в PDF) + + )}
- {/* OCR Progress */} - {ocrProgress && ( -
-
- {ocrProgress.includes('🔍') || ocrProgress.includes('🔄') ? ( - - ) : null} - {ocrProgress} -
- {ocrProgress.includes('Обработка') && ( - - )} -
+ {/* Прогресс обработки */} + {uploading && uploadProgress && ( + } />} + style={{ marginBottom: 16 }} + /> )} @@ -397,6 +465,7 @@ export default function Step1Policy({ formData, updateFormData, onNext, addDebug setFileList([]); }} size="large" + disabled={uploading} > Отмена @@ -407,7 +476,7 @@ export default function Step1Policy({ formData, updateFormData, onNext, addDebug size="large" style={{ flex: 1 }} > - {uploading ? 'Загрузка...' : 'Продолжить со сканом'} + {uploading ? 'Обрабатываем...' : 'Продолжить со сканом'} diff --git a/frontend/src/components/form/Step2Details.tsx b/frontend/src/components/form/Step2Details.tsx index b674c73..c0bcbf8 100644 --- a/frontend/src/components/form/Step2Details.tsx +++ b/frontend/src/components/form/Step2Details.tsx @@ -1,5 +1,5 @@ -import { Form, Input, Button, Select, DatePicker, Upload, message } from 'antd'; -import { UploadOutlined } from '@ant-design/icons'; +import { Form, Input, Button, Select, DatePicker, Upload, message, Spin, Alert } from 'antd'; +import { UploadOutlined, LoadingOutlined } from '@ant-design/icons'; import { useState } from 'react'; import type { UploadFile } from 'antd/es/upload/interface'; import dayjs from 'dayjs'; @@ -29,6 +29,7 @@ export default function Step2Details({ formData, updateFormData, onNext, onPrev, const [form] = Form.useForm(); const [fileList, setFileList] = useState([]); const [uploading, setUploading] = useState(false); + const [uploadProgress, setUploadProgress] = useState(''); const handleNext = async () => { try { @@ -37,28 +38,61 @@ export default function Step2Details({ formData, updateFormData, onNext, onPrev, // Если есть файлы - загружаем if (fileList.length > 0) { setUploading(true); + setUploadProgress('📤 Подготавливаем документы...'); - addDebugEvent?.('upload', 'pending', `📤 Загружаю ${fileList.length} документ(ов) в S3...`, { + addDebugEvent?.('upload', 'pending', `📤 Загружаю ${fileList.length} документ(ов) в S3 через n8n...`, { count: fileList.length }); - const formData = new FormData(); - fileList.forEach((file: any) => { - if (file.originFileObj) { - formData.append('files', file.originFileObj); + // Используем claim_id из formData (уже сгенерирован в Step1) + const claimId = formData.claim_id; + + // Загружаем каждый документ через n8n вебхук + const uploadedFiles = []; + + for (let i = 0; i < fileList.length; i++) { + const file = fileList[i]; + if (!file.originFileObj) continue; + + setUploadProgress(`📡 Загружаем документ ${i + 1} из ${fileList.length}: ${file.name}...`); + + const uploadFormData = new FormData(); + uploadFormData.append('claim_id', claimId); + uploadFormData.append('file_type', `document_${i + 1}`); // document_1, document_2, etc + uploadFormData.append('filename', file.name); + uploadFormData.append('voucher', formData.voucher || ''); + uploadFormData.append('session_id', sessionStorage.getItem('session_id') || 'unknown'); + uploadFormData.append('upload_timestamp', new Date().toISOString()); + uploadFormData.append('file', file.originFileObj); + + const uploadResponse = await fetch('https://n8n.clientright.pro/webhook/7e2abc64-eaca-4671-86e4-12786700fe95', { + method: 'POST', + body: uploadFormData, + }); + + setUploadProgress(`🔍 Обрабатываем документ ${i + 1} из ${fileList.length}...`); + const uploadResult = await uploadResponse.json(); + + const resultData = Array.isArray(uploadResult) ? uploadResult[0] : uploadResult; + if (resultData?.success) { + uploadedFiles.push({ + filename: file.name, + success: true + }); } - }); + } - const uploadResponse = await fetch('http://147.45.146.17:8100/api/v1/upload/files?folder=documents', { - method: 'POST', - body: formData, - }); - - const uploadResult = await uploadResponse.json(); + const uploadResult = { + success: uploadedFiles.length > 0, + uploaded_count: uploadedFiles.length, + total_count: fileList.length, + files: uploadedFiles + }; if (uploadResult.success) { - addDebugEvent?.('upload', 'success', `✅ Документы загружены: ${uploadResult.uploaded_count}/${uploadResult.total_count}`, { - files: uploadResult.files + addDebugEvent?.('upload', 'success', `✅ Документы загружены через n8n: ${uploadResult.uploaded_count}/${uploadResult.total_count}`, { + files: uploadResult.files, + claim_id: claimId }); updateFormData({ @@ -68,10 +102,12 @@ export default function Step2Details({ formData, updateFormData, onNext, onPrev, } else { message.error('Ошибка загрузки документов'); setUploading(false); + setUploadProgress(''); return; } setUploading(false); + setUploadProgress(''); } else { updateFormData(values); } @@ -80,6 +116,7 @@ export default function Step2Details({ formData, updateFormData, onNext, onPrev, } catch (error) { message.error('Заполните все обязательные поля'); setUploading(false); + setUploadProgress(''); } }; @@ -226,9 +263,18 @@ export default function Step2Details({ formData, updateFormData, onNext, onPrev, message.error(`${file.name}: файл больше 15MB`); return Upload.LIST_IGNORE; } + + const validTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp', 'application/pdf']; + const validExtensions = /\.(jpg|jpeg|png|pdf|heic|heif|webp)$/i; + + if (!validTypes.includes(file.type) && !validExtensions.test(file.name)) { + message.error(`${file.name}: неподдерживаемый формат`); + return Upload.LIST_IGNORE; + } + return false; }} - accept="image/*,.pdf,.heic,.heif" + accept="image/*,.pdf,.heic,.heif,.webp" multiple maxCount={5} > @@ -254,13 +300,23 @@ export default function Step2Details({ formData, updateFormData, onNext, onPrev, message.error(`${file.name}: файл больше 15MB`); return Upload.LIST_IGNORE; } + if (fileList.length >= 10) { message.error('Максимум 10 файлов'); return Upload.LIST_IGNORE; } + + const validTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp', 'application/pdf']; + const validExtensions = /\.(jpg|jpeg|png|pdf|heic|heif|webp)$/i; + + if (!validTypes.includes(file.type) && !validExtensions.test(file.name)) { + message.error(`${file.name}: неподдерживаемый формат`); + return Upload.LIST_IGNORE; + } + return false; }} - accept="image/*,.pdf,.heic,.heif" + accept="image/*,.pdf,.heic,.heif,.webp" multiple maxCount={10} showUploadList={{ @@ -277,9 +333,20 @@ export default function Step2Details({ formData, updateFormData, onNext, onPrev, + {/* Прогресс обработки */} + {uploading && uploadProgress && ( + } />} + style={{ marginBottom: 16, marginTop: 16 }} + /> + )} +
- +
diff --git a/frontend/src/pages/ClaimForm.tsx b/frontend/src/pages/ClaimForm.tsx index 6731864..ad7a32d 100644 --- a/frontend/src/pages/ClaimForm.tsx +++ b/frontend/src/pages/ClaimForm.tsx @@ -28,6 +28,23 @@ interface FormData { } export default function ClaimForm() { + // Генерируем claim_id один раз при загрузке формы + const [claimId] = useState(() => { + const date = new Date().toISOString().split('T')[0]; + const randomId = Math.random().toString(36).substr(2, 6).toUpperCase(); + return `CLM-${date}-${randomId}`; + }); + + // Генерируем session_id и сохраняем в sessionStorage + const [sessionId] = useState(() => { + let sid = sessionStorage.getItem('session_id'); + if (!sid) { + sid = `sess-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + sessionStorage.setItem('session_id', sid); + } + return sid; + }); + const [currentStep, setCurrentStep] = useState(0); const [formData, setFormData] = useState({ voucher: '', @@ -44,11 +61,23 @@ export default function ClaimForm() { type, status, message, - data + data: { + ...data, + claim_id: claimId // Добавляем claim_id во все события + } }; setDebugEvents(prev => [event, ...prev]); }; + // Логируем генерацию claim_id и session_id при первой загрузке + useState(() => { + addDebugEvent('system', 'info', `🆔 Сгенерирован Claim ID: ${claimId}`, { + claim_id: claimId, + session_id: sessionId, + timestamp: new Date().toISOString() + }); + }); + const updateFormData = (data: Partial) => { setFormData({ ...formData, ...data }); }; @@ -109,9 +138,9 @@ export default function ClaimForm() { { title: 'Проверка полиса', content: ( - @@ -121,7 +150,7 @@ export default function ClaimForm() { title: 'Детали происшествия', content: ( { + // Если уже PDF - проверяем размер + if (file.type === 'application/pdf') { + const sizeMB = file.size / (1024 * 1024); + console.log(`📄 File is already PDF: ${file.name} (${sizeMB.toFixed(2)}MB)`); + + // Если PDF больше 10MB - отклоняем + if (sizeMB > 10) { + throw new Error( + `❌ PDF файл слишком большой: ${sizeMB.toFixed(1)} MB.\n\n` + + `Максимальный размер: 10 MB.\n` + + `Пожалуйста, сожмите PDF перед загрузкой (например, на https://www.ilovepdf.com/compress_pdf)` + ); + } + + // Если PDF больше 5MB - предупреждаем + if (sizeMB > 5) { + console.warn(`⚠️ Large PDF: ${sizeMB.toFixed(2)}MB - will be sent to server compression`); + } + + return file; + } + + // Конвертируем изображения в PDF + if (file.type.startsWith('image/') || file.name.match(/\.(heic|heif)$/i)) { + console.log('🖼️ Converting image to PDF:', file.name, `(${file.type || 'unknown type'})`); + + try { + // Для HEIC/HEIF браузер может не знать MIME type + // browser-image-compression автоматически конвертирует в JPEG + + // 1. Сжимаем изображение (макс 2MB, 2000px) + const compressed = await imageCompression(file, { + maxSizeMB: 2, + maxWidthOrHeight: 2000, + useWebWorker: true, + fileType: 'image/jpeg' // Конвертируем всё в JPEG для PDF + }); + + console.log(`✅ Compressed: ${(file.size / 1024 / 1024).toFixed(2)}MB → ${(compressed.size / 1024 / 1024).toFixed(2)}MB`); + + // 2. Получаем data URL + const dataUrl = await imageCompression.getDataUrlFromFile(compressed); + + // 3. Создаём PDF документ (A4) + const pdf = new jsPDF({ + orientation: 'portrait', + unit: 'mm', + format: 'a4', + compress: true + }); + + // 4. Получаем размеры изображения + const imgProps = pdf.getImageProperties(dataUrl); + const pdfWidth = pdf.internal.pageSize.getWidth(); + const pdfHeight = pdf.internal.pageSize.getHeight(); + + // Подгоняем под размер A4 с сохранением пропорций + const imgWidth = pdfWidth; + const imgHeight = (imgProps.height * pdfWidth) / imgProps.width; + + // Если изображение выше A4 - уменьшаем + const finalHeight = imgHeight > pdfHeight ? pdfHeight : imgHeight; + const finalWidth = imgHeight > pdfHeight ? (imgProps.width * pdfHeight) / imgProps.height : imgWidth; + + // 5. Добавляем изображение на страницу + pdf.addImage(dataUrl, 'JPEG', 0, 0, finalWidth, finalHeight); + + // 6. Получаем PDF blob + const pdfBlob = pdf.output('blob'); + + // 7. Создаём новый File объект + const pdfFileName = file.name.replace(/\.(jpg|jpeg|png|heic|heif)$/i, '.pdf'); + const pdfFile = new File([pdfBlob], pdfFileName, { + type: 'application/pdf', + lastModified: Date.now() + }); + + console.log(`✅ PDF created: ${pdfFileName} (${(pdfFile.size / 1024 / 1024).toFixed(2)}MB)`); + + return pdfFile; + + } catch (error) { + console.error('❌ PDF conversion error:', error); + throw new Error('Ошибка конвертации в PDF'); + } + } + + // DOCX/DOC не поддерживается в браузере + if (file.name.match(/\.(doc|docx)$/i)) { + console.warn('⚠️ DOCX files - n8n will convert'); + return file; // Отправляем как есть, n8n сконвертирует + } + + // Неподдерживаемый формат + console.error('❌ Unsupported file type:', file.type); + throw new Error(`Неподдерживаемый формат файла: ${file.type}`); +} + +/** + * Проверяет нужна ли конвертация + */ +export function needsConversion(file: File): boolean { + return !file.type.includes('pdf'); +} + diff --git a/monitor_redis.py b/monitor_redis.py new file mode 100755 index 0000000..fc1c2b9 --- /dev/null +++ b/monitor_redis.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +""" +Redis Channel Monitor - слушает события на каналах ocr_events:* +""" +import redis +import json +import sys +from datetime import datetime + +def monitor_redis_channels(): + try: + # Подключаемся к Redis + r = redis.Redis(host='crm.clientright.ru', port=6379, decode_responses=True) + + # Создаем PubSub объект + pubsub = r.pubsub() + + # Подписываемся на pattern + pubsub.psubscribe('ocr_events:*') + + print(f"🔊 Слушаем Redis каналы: ocr_events:*") + print(f"⏰ Начало мониторинга: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") + print("=" * 80) + print() + + # Слушаем сообщения + for message in pubsub.listen(): + if message['type'] == 'pmessage': + channel = message['channel'] + data = message['data'] + timestamp = datetime.now().strftime('%H:%M:%S.%f')[:-3] + + print(f"⏰ {timestamp}") + print(f"📢 Канал: {channel}") + + # Пытаемся распарсить JSON + try: + parsed_data = json.loads(data) + print(f"📦 Данные:") + print(json.dumps(parsed_data, indent=2, ensure_ascii=False)) + except json.JSONDecodeError: + print(f"📦 Данные (raw): {data}") + + print("-" * 80) + print() + + except KeyboardInterrupt: + print("\n\n⛔ Мониторинг остановлен") + sys.exit(0) + except Exception as e: + print(f"❌ Ошибка: {e}") + sys.exit(1) + +if __name__ == '__main__': + monitor_redis_channels() + + diff --git a/monitor_redis.sh b/monitor_redis.sh new file mode 100755 index 0000000..4ec5a31 --- /dev/null +++ b/monitor_redis.sh @@ -0,0 +1,15 @@ +#!/bin/bash +# Redis Channel Monitor - слушает события на каналах ocr_events:* + +REDIS_PASSWORD="CRM_Redis_Pass_2025_Secure!" + +echo "🔊 Слушаем Redis каналы: ocr_events:*" +echo "⏰ Начало мониторинга: $(date '+%Y-%m-%d %H:%M:%S')" +echo "================================================================================" +echo "" + +redis-cli -h crm.clientright.ru -p 6379 -a "$REDIS_PASSWORD" PSUBSCRIBE 'ocr_events:*' 2>/dev/null | while read -r line; do + timestamp=$(date '+%H:%M:%S.%3N') + echo "[$timestamp] $line" +done + diff --git a/start_worker.sh b/start_worker.sh new file mode 100755 index 0000000..5fac80b --- /dev/null +++ b/start_worker.sh @@ -0,0 +1,14 @@ +#!/bin/bash +# Запуск OCR Worker + +cd /var/www/fastuser/data/www/crm.clientright.ru/erv_platform/backend +source venv/bin/activate + +echo "🚀 Starting OCR Worker..." +python -m app.workers.ocr_worker + + + + + + diff --git a/test_redis_events.sh b/test_redis_events.sh new file mode 100755 index 0000000..469aa9c --- /dev/null +++ b/test_redis_events.sh @@ -0,0 +1,86 @@ +#!/bin/bash +# Тест Redis Pub/Sub через HTTP эндпоинты + +TASK_ID="test-$(date +%s)" +BASE_URL="http://localhost:8100/api/v1" + +echo "🧪 Testing Redis Pub/Sub Events" +echo "================================" +echo "Task ID: $TASK_ID" +echo "" + +# В фоне запускаем SSE подписку +echo "📡 Starting SSE listener..." +curl -N "$BASE_URL/events/$TASK_ID" & +SSE_PID=$! +sleep 2 + +# Публикуем события +echo "" +echo "📢 Publishing events..." +echo "" + +echo "1️⃣ Processing started..." +curl -X POST "$BASE_URL/events/$TASK_ID" \ + -H "Content-Type: application/json" \ + -d '{ + "status": "processing", + "message": "Начата обработка файла", + "data": {"filename": "test.pdf"} + }' | jq '.' + +sleep 2 + +echo "" +echo "2️⃣ OCR started..." +curl -X POST "$BASE_URL/events/$TASK_ID" \ + -H "Content-Type: application/json" \ + -d '{ + "status": "ocr_started", + "message": "Запущено распознавание текста", + "data": {} + }' | jq '.' + +sleep 3 + +echo "" +echo "3️⃣ OCR completed..." +curl -X POST "$BASE_URL/events/$TASK_ID" \ + -H "Content-Type: application/json" \ + -d '{ + "status": "ocr_completed", + "message": "Распознано 1500 символов", + "data": {"chars": 1500} + }' | jq '.' + +sleep 2 + +echo "" +echo "4️⃣ Completed..." +curl -X POST "$BASE_URL/events/$TASK_ID" \ + -H "Content-Type: application/json" \ + -d '{ + "status": "completed", + "message": "Обработка завершена", + "data": { + "document_type": "policy", + "is_valid": true, + "confidence": 0.95 + } + }' | jq '.' + +sleep 2 + +# Убиваем SSE слушатель +echo "" +echo "🛑 Stopping SSE listener..." +kill $SSE_PID 2>/dev/null + +echo "" +echo "✅ Test completed!" + + + + + +