Unified auth and sessions: POST /api/v1/auth, session by channel:id and token, need_contact fix, n8n parsing, TTL 24h
This commit is contained in:
@@ -9,12 +9,108 @@ from pydantic import BaseModel
|
||||
from typing import Dict, Any
|
||||
from app.services.redis_service import redis_service
|
||||
from app.services.database import db
|
||||
from app.config import settings
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/v1", tags=["Events"])
|
||||
|
||||
# Типы для единого отображения на фронте: тип + текст (+ data для consumer_complaint)
|
||||
DISPLAY_EVENT_TYPES = ("trash_message", "out_of_scope", "consumer_consultation", "consumer_complaint")
|
||||
|
||||
|
||||
def _normalize_display_event(actual_event: dict) -> dict:
|
||||
"""
|
||||
Приводит событие к формату { event_type, message [, data] } для единого отображения.
|
||||
event_type — один из: trash_message (красный), out_of_scope (жёлтый),
|
||||
consumer_consultation (синий), consumer_complaint (зелёный).
|
||||
"""
|
||||
raw_type = actual_event.get("event_type") or actual_event.get("type")
|
||||
payload = actual_event.get("payload") or actual_event.get("data") or {}
|
||||
if isinstance(payload, str):
|
||||
try:
|
||||
payload = json.loads(payload) if payload else {}
|
||||
except Exception:
|
||||
payload = {}
|
||||
if not isinstance(payload, dict):
|
||||
payload = {}
|
||||
msg = (actual_event.get("message") or payload.get("message") or "").strip() or "Ответ получен"
|
||||
|
||||
# Если n8n уже прислал один из четырёх типов — не перезаписываем, отдаём как есть (синий/зелёный не превращаем в жёлтый)
|
||||
if raw_type in DISPLAY_EVENT_TYPES:
|
||||
return {
|
||||
"event_type": raw_type,
|
||||
"message": msg or "Ответ получен",
|
||||
"data": actual_event.get("data", {}),
|
||||
"suggested_actions": (actual_event.get("suggested_actions") or payload.get("suggested_actions")) if raw_type == "out_of_scope" else None,
|
||||
}
|
||||
|
||||
if raw_type == "trash_message" or payload.get("intent") == "trash":
|
||||
return {
|
||||
"event_type": "trash_message",
|
||||
"message": msg or "К сожалению, это обращение не по тематике.",
|
||||
"data": actual_event.get("data", {}),
|
||||
}
|
||||
if raw_type == "out_of_scope":
|
||||
return {
|
||||
"event_type": "out_of_scope",
|
||||
"message": msg or "К сожалению, мы не можем помочь с этим вопросом.",
|
||||
"data": actual_event.get("data", {}),
|
||||
"suggested_actions": actual_event.get("suggested_actions") or payload.get("suggested_actions"),
|
||||
}
|
||||
if raw_type == "consumer_intent":
|
||||
intent = payload.get("intent") or actual_event.get("intent")
|
||||
if intent == "consultation":
|
||||
return {
|
||||
"event_type": "consumer_consultation",
|
||||
"message": msg or "Понял. Это похоже на консультацию.",
|
||||
"data": {},
|
||||
}
|
||||
return {
|
||||
"event_type": "consumer_complaint",
|
||||
"message": msg or "Обращение принято.",
|
||||
"data": actual_event.get("data", {}),
|
||||
}
|
||||
if raw_type == "documents_list_ready":
|
||||
return {
|
||||
"event_type": "consumer_complaint",
|
||||
"message": msg or "Подготовлен список документов.",
|
||||
"data": {
|
||||
**actual_event.get("data", {}),
|
||||
"documents_required": actual_event.get("documents_required"),
|
||||
"claim_id": actual_event.get("claim_id"),
|
||||
},
|
||||
}
|
||||
if raw_type in ("wizard_ready", "wizard_plan_ready", "claim_plan_ready"):
|
||||
return {
|
||||
"event_type": "consumer_complaint",
|
||||
"message": msg or "План готов.",
|
||||
"data": actual_event.get("data", actual_event),
|
||||
}
|
||||
if raw_type == "ocr_status" and actual_event.get("status") == "ready":
|
||||
return {
|
||||
"event_type": "consumer_complaint",
|
||||
"message": msg or "Данные подтверждены.",
|
||||
"data": actual_event.get("data", {}),
|
||||
}
|
||||
# Если есть текст сообщения, но тип неизвестен — считаем out_of_scope, чтобы фронт точно показал ответ
|
||||
if msg and msg.strip() and raw_type not in (
|
||||
"documents_list_ready", "document_uploaded", "document_ocr_completed",
|
||||
"ocr_status", "claim_ready", "claim_plan_ready", "claim_plan_error",
|
||||
):
|
||||
return {
|
||||
"event_type": "out_of_scope",
|
||||
"message": msg.strip(),
|
||||
"data": actual_event.get("data", {}),
|
||||
"suggested_actions": actual_event.get("suggested_actions"),
|
||||
}
|
||||
# Остальные события — прозрачно, только дополняем message
|
||||
out = dict(actual_event)
|
||||
if "message" not in out or not out.get("message"):
|
||||
out["message"] = msg
|
||||
return out
|
||||
|
||||
|
||||
class EventPublish(BaseModel):
|
||||
"""Модель для публикации события"""
|
||||
@@ -84,7 +180,10 @@ async def stream_events(task_id: str):
|
||||
Returns:
|
||||
StreamingResponse с событиями
|
||||
"""
|
||||
logger.info(f"🚀 SSE connection requested for session_token: {task_id}")
|
||||
logger.info(
|
||||
"🚀 SSE connection requested for session_token: %s → channel=ocr_events:%s (Redis %s:%s)",
|
||||
task_id, task_id, settings.redis_host, settings.redis_port,
|
||||
)
|
||||
|
||||
async def event_generator():
|
||||
"""Генератор событий из Redis Pub/Sub"""
|
||||
@@ -95,7 +194,10 @@ async def stream_events(task_id: str):
|
||||
pubsub = redis_service.client.pubsub()
|
||||
await pubsub.subscribe(channel)
|
||||
|
||||
logger.info(f"📡 Client subscribed to {channel}")
|
||||
logger.info(
|
||||
"📡 Subscribed to channel=%s on Redis %s:%s (проверка: redis-cli -h %s PUBSUB NUMSUB %s)",
|
||||
channel, settings.redis_host, settings.redis_port, settings.redis_host, channel,
|
||||
)
|
||||
|
||||
# Отправляем начальное событие
|
||||
yield f"data: {json.dumps({'status': 'connected', 'message': 'Подключено к событиям'})}\n\n"
|
||||
@@ -298,10 +400,14 @@ async def stream_events(task_id: str):
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error loading form_draft from PostgreSQL: {e}")
|
||||
|
||||
# Единый формат для фронта: событие с полями event_type и message (и data при необходимости)
|
||||
raw_event_type = actual_event.get("event_type")
|
||||
raw_status = actual_event.get("status")
|
||||
actual_event = _normalize_display_event(actual_event)
|
||||
# Отправляем событие клиенту (плоский формат)
|
||||
event_json = json.dumps(actual_event, ensure_ascii=False, default=str)
|
||||
event_type_sent = actual_event.get('event_type', 'unknown')
|
||||
event_status = actual_event.get('status', 'unknown')
|
||||
event_type_sent = actual_event.get("event_type", "unknown")
|
||||
event_status = actual_event.get("status") or (actual_event.get("data") or {}).get("status") or "unknown"
|
||||
# Логируем размер и наличие данных
|
||||
data_info = actual_event.get('data', {})
|
||||
has_form_draft = 'form_draft' in data_info if isinstance(data_info, dict) else False
|
||||
@@ -310,18 +416,21 @@ async def stream_events(task_id: str):
|
||||
|
||||
# Если обработка завершена - закрываем соединение
|
||||
# НЕ закрываем для documents_list_ready и document_ocr_completed (ждём ещё события)
|
||||
if event_status in ['completed', 'error'] and event_type_sent not in ['documents_list_ready', 'document_ocr_completed', 'document_uploaded']:
|
||||
if event_status in ['completed', 'error'] and (raw_event_type or event_type_sent) not in ['documents_list_ready', 'document_ocr_completed', 'document_uploaded']:
|
||||
logger.info(f"✅ Task {task_id} finished, closing SSE")
|
||||
break
|
||||
|
||||
# Закрываем для финальных событий
|
||||
# Закрываем для финальных событий (raw_event_type до нормализации)
|
||||
if raw_event_type in ['claim_ready', 'claim_plan_ready', 'wizard_ready', 'wizard_plan_ready']:
|
||||
logger.info(f"✅ Final event {raw_event_type} sent, closing SSE")
|
||||
break
|
||||
if event_type_sent in ['claim_ready', 'claim_plan_ready']:
|
||||
logger.info(f"✅ Final event {event_type_sent} sent, closing SSE")
|
||||
break
|
||||
|
||||
# Закрываем для ocr_status ready (форма заявления готова)
|
||||
if event_type_sent == 'ocr_status' and event_status == 'ready':
|
||||
logger.info(f"✅ OCR ready event sent, closing SSE")
|
||||
if raw_event_type == "ocr_status" and raw_status == "ready":
|
||||
logger.info("✅ OCR ready event sent, closing SSE")
|
||||
break
|
||||
else:
|
||||
logger.info(f"⏰ Timeout waiting for message on {channel}")
|
||||
@@ -369,7 +478,10 @@ async def stream_claim_plan(session_token: str):
|
||||
}
|
||||
}
|
||||
"""
|
||||
logger.info(f"🚀 Claim plan SSE connection requested for session_token: {session_token}")
|
||||
logger.info(
|
||||
"🚀 Claim plan SSE: session_token=%s → channel=claim:plan:%s (Redis %s:%s)",
|
||||
session_token, session_token, settings.redis_host, settings.redis_port,
|
||||
)
|
||||
|
||||
async def claim_plan_generator():
|
||||
"""Генератор событий из Redis Pub/Sub для claim:plan канала"""
|
||||
@@ -379,7 +491,10 @@ async def stream_claim_plan(session_token: str):
|
||||
pubsub = redis_service.client.pubsub()
|
||||
await pubsub.subscribe(channel)
|
||||
|
||||
logger.info(f"📡 Client subscribed to {channel}")
|
||||
logger.info(
|
||||
"📡 Subscribed to channel=%s on Redis %s:%s (PUBSUB NUMSUB %s)",
|
||||
channel, settings.redis_host, settings.redis_port, channel,
|
||||
)
|
||||
|
||||
# Отправляем начальное событие
|
||||
yield f"data: {json.dumps({'status': 'connected', 'message': 'Ожидание данных заявления...'})}\n\n"
|
||||
|
||||
Reference in New Issue
Block a user