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:
Fedor
2026-02-24 16:17:59 +03:00
parent 6350f9015b
commit d8fe0b605b
23 changed files with 1785 additions and 449 deletions

View File

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