feat: Интеграция n8n + Redis Pub/Sub + SSE для real-time обработки заявок

🎯 Основные изменения:

Backend:
-  Добавлен SSE endpoint для real-time событий (/api/v1/events/{task_id})
-  Redis Pub/Sub для публикации/подписки на события OCR/Vision
-  Удален aioboto3 из requirements.txt (конфликт зависимостей)
-  Добавлен OCR worker (deprecated, логика перенесена в n8n)

Frontend (React):
-  Автогенерация claim_id и session_id
-  Клиентская конвертация файлов в PDF (JPG/PNG/HEIC/WEBP)
-  Сжатие изображений до 2MB перед конвертацией
-  SSE подписка на события OCR/Vision в Step1Policy
-  Валидация документов (полис vs неподходящий контент)
-  Real-time прогресс загрузки и обработки файлов
-  Интеграция с n8n webhooks для проверки полиса и загрузки файлов

n8n Workflows:
-  Проверка полиса в MySQL + запись в PostgreSQL
-  Загрузка файлов в S3 + OCR + Vision AI
-  Публикация событий в Redis через backend API
-  Валидация документов (распознавание полисов ERV)

Документация:
- 📝 N8N_INTEGRATION.md - интеграция с n8n
- 📝 N8N_SQL_QUERIES.md - SQL запросы для workflows
- 📝 N8N_PDF_COMPRESS.md - сжатие PDF
- 📝 N8N_STIRLING_COMPRESS.md - интеграция Stirling-PDF

Утилиты:
- 🔧 monitor_redis.py/sh - мониторинг Redis Pub/Sub
- 🔧 test_redis_events.sh - тестирование событий
- 🔧 pdfConverter.ts - клиентская конвертация в PDF

Архитектура:
React → n8n webhooks (sync) → MySQL/PostgreSQL/S3
      → n8n workflows (async) → OCR/Vision → Redis Pub/Sub → SSE → React
This commit is contained in:
AI Assistant
2025-10-27 08:33:16 +03:00
parent 1be922fdc3
commit 647abf6578
20 changed files with 2177 additions and 168 deletions

View File

@@ -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 анализ - что это за документ?

View File

@@ -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}"

View File

@@ -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}"
# Глобальный экземпляр