.
This commit is contained in:
847
src/capa_de_integracion/services/conversation_manager.py
Normal file
847
src/capa_de_integracion/services/conversation_manager.py
Normal file
@@ -0,0 +1,847 @@
|
||||
"""
|
||||
Copyright 2025 Google. This software is provided as-is, without warranty or
|
||||
representation for any use or purpose. Your use of it is subject to your
|
||||
agreement with Google.
|
||||
|
||||
Conversation manager service - central orchestrator for conversations.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from ..config import Settings
|
||||
from ..models import (
|
||||
ExternalConvRequestDTO,
|
||||
DetectIntentRequestDTO,
|
||||
DetectIntentResponseDTO,
|
||||
ConversationSessionDTO,
|
||||
ConversationEntryDTO,
|
||||
QueryInputDTO,
|
||||
TextInputDTO,
|
||||
QueryParamsDTO,
|
||||
)
|
||||
from ..utils import SessionIdGenerator
|
||||
from .dialogflow_client import DialogflowClientService
|
||||
from .redis_service import RedisService
|
||||
from .firestore_service import FirestoreService
|
||||
from .dlp_service import DLPService
|
||||
from .message_filter import MessageEntryFilter
|
||||
from .notification_context_resolver import NotificationContextResolver
|
||||
from .llm_response_tuner import LlmResponseTunerService
|
||||
from .mappers import NotificationContextMapper, ConversationContextMapper
|
||||
from .quick_reply_content import QuickReplyContentService
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ConversationManagerService:
|
||||
"""
|
||||
Central orchestrator for managing user conversations.
|
||||
|
||||
Integrates Data Loss Prevention (DLP), message classification, routing based
|
||||
on session context (pantallaContexto for quick replies), and hybrid AI logic
|
||||
for notification-driven conversations.
|
||||
|
||||
Routes traffic based on session context:
|
||||
1. If 'pantallaContexto' is present (not stale), delegates to QuickRepliesManagerService
|
||||
2. Otherwise, uses MessageEntryFilter (Gemini) to classify message:
|
||||
a) CONVERSATION: Standard Dialogflow flow with conversation history
|
||||
b) NOTIFICATION: Uses NotificationContextResolver (Gemini) to answer or delegate to Dialogflow
|
||||
|
||||
All conversation turns are persisted using reactive write-back pattern:
|
||||
Redis first (fast), then async to Firestore (persistent).
|
||||
"""
|
||||
|
||||
SESSION_RESET_THRESHOLD_MINUTES = 30
|
||||
SCREEN_CONTEXT_TIMEOUT_MINUTES = 10
|
||||
CONV_HISTORY_PARAM = "conversation_history"
|
||||
HISTORY_PARAM = "historial"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
settings: Settings,
|
||||
dialogflow_client: DialogflowClientService,
|
||||
redis_service: RedisService,
|
||||
firestore_service: FirestoreService,
|
||||
dlp_service: DLPService,
|
||||
message_filter: MessageEntryFilter,
|
||||
notification_context_resolver: NotificationContextResolver,
|
||||
llm_response_tuner: LlmResponseTunerService,
|
||||
):
|
||||
"""Initialize conversation manager."""
|
||||
self.settings = settings
|
||||
self.dialogflow_client = dialogflow_client
|
||||
self.redis_service = redis_service
|
||||
self.firestore_service = firestore_service
|
||||
self.dlp_service = dlp_service
|
||||
self.message_filter = message_filter
|
||||
self.notification_context_resolver = notification_context_resolver
|
||||
self.llm_response_tuner = llm_response_tuner
|
||||
|
||||
# Initialize mappers
|
||||
self.notification_mapper = NotificationContextMapper()
|
||||
self.conversation_mapper = ConversationContextMapper(
|
||||
message_limit=settings.conversation_context_message_limit,
|
||||
days_limit=settings.conversation_context_days_limit,
|
||||
)
|
||||
|
||||
# Quick reply service
|
||||
self.quick_reply_service = QuickReplyContentService(settings)
|
||||
|
||||
logger.info("ConversationManagerService initialized successfully")
|
||||
|
||||
async def manage_conversation(
|
||||
self, request: ExternalConvRequestDTO
|
||||
) -> DetectIntentResponseDTO:
|
||||
"""
|
||||
Main entry point for managing conversations.
|
||||
|
||||
Flow:
|
||||
1. Obfuscate message with DLP
|
||||
2. Check for pantallaContexto (quick replies mode)
|
||||
3. If no pantallaContexto, continue with standard flow
|
||||
4. Classify message (CONVERSATION vs NOTIFICATION)
|
||||
5. Route to appropriate handler
|
||||
|
||||
Args:
|
||||
request: External conversation request from client
|
||||
|
||||
Returns:
|
||||
Detect intent response from Dialogflow
|
||||
"""
|
||||
try:
|
||||
# Step 1: DLP obfuscation
|
||||
obfuscated_message = await self.dlp_service.get_obfuscated_string(
|
||||
request.mensaje,
|
||||
self.settings.dlp_template_complete_flow,
|
||||
)
|
||||
|
||||
obfuscated_request = ExternalConvRequestDTO(
|
||||
mensaje=obfuscated_message,
|
||||
usuario=request.usuario,
|
||||
canal=request.canal,
|
||||
tipo=request.tipo,
|
||||
pantalla_contexto=request.pantalla_contexto,
|
||||
)
|
||||
|
||||
# Step 2: Check for pantallaContexto in existing session
|
||||
telefono = request.usuario.telefono
|
||||
existing_session = await self.redis_service.get_session(telefono)
|
||||
|
||||
if existing_session and existing_session.pantallaContexto:
|
||||
# Check if pantallaContexto is stale (10 minutes)
|
||||
if self._is_pantalla_context_valid(existing_session):
|
||||
logger.info(
|
||||
f"Detected 'pantallaContexto' in session: {existing_session.pantallaContexto}. "
|
||||
f"Delegating to QuickReplies flow."
|
||||
)
|
||||
return await self._manage_quick_reply_conversation(
|
||||
obfuscated_request, existing_session
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"Detected STALE 'pantallaContexto'. Ignoring and proceeding with normal flow."
|
||||
)
|
||||
|
||||
# Step 3: Continue with standard conversation flow
|
||||
return await self._continue_managing_conversation(obfuscated_request)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error managing conversation: {str(e)}", exc_info=True)
|
||||
raise
|
||||
|
||||
def _is_pantalla_context_valid(self, session: ConversationSessionDTO) -> bool:
|
||||
"""Check if pantallaContexto is still valid (not stale)."""
|
||||
if not session.lastModified:
|
||||
return False
|
||||
|
||||
time_diff = datetime.now() - session.lastModified
|
||||
return time_diff < timedelta(minutes=self.SCREEN_CONTEXT_TIMEOUT_MINUTES)
|
||||
|
||||
async def _manage_quick_reply_conversation(
|
||||
self,
|
||||
request: ExternalConvRequestDTO,
|
||||
session: ConversationSessionDTO,
|
||||
) -> DetectIntentResponseDTO:
|
||||
"""
|
||||
Handle conversation within Quick Replies context.
|
||||
|
||||
User is in a quick reply screen, treat their message as a FAQ query.
|
||||
|
||||
Args:
|
||||
request: External request
|
||||
session: Existing session with pantallaContexto
|
||||
|
||||
Returns:
|
||||
Dialogflow response
|
||||
"""
|
||||
# Build Dialogflow request with pantallaContexto
|
||||
dialogflow_request = self._build_dialogflow_request(
|
||||
request, session, request.mensaje
|
||||
)
|
||||
|
||||
# Add pantallaContexto to parameters
|
||||
if dialogflow_request.query_params:
|
||||
dialogflow_request.query_params.parameters["pantalla_contexto"] = (
|
||||
session.pantallaContexto
|
||||
)
|
||||
|
||||
# Call Dialogflow
|
||||
response = await self.dialogflow_client.detect_intent(
|
||||
session.sessionId, dialogflow_request
|
||||
)
|
||||
|
||||
# Persist conversation turn
|
||||
await self._persist_conversation_turn(session, request.mensaje, response)
|
||||
|
||||
return response
|
||||
|
||||
async def _continue_managing_conversation(
|
||||
self, request: ExternalConvRequestDTO
|
||||
) -> DetectIntentResponseDTO:
|
||||
"""
|
||||
Continue with standard conversation flow.
|
||||
|
||||
Steps:
|
||||
1. Get or create session
|
||||
2. Check for active notifications
|
||||
3. Classify message (CONVERSATION vs NOTIFICATION)
|
||||
4. Route to appropriate handler
|
||||
|
||||
Args:
|
||||
request: External conversation request
|
||||
|
||||
Returns:
|
||||
Dialogflow response
|
||||
"""
|
||||
telefono = request.usuario.telefono
|
||||
nickname = (
|
||||
request.usuario.nickname if hasattr(request.usuario, "nickname") else None
|
||||
)
|
||||
|
||||
if not telefono or not telefono.strip():
|
||||
raise ValueError("Phone number is required to manage conversation sessions")
|
||||
|
||||
logger.info(f"Primary Check (Redis): Looking up session for phone: {telefono}")
|
||||
|
||||
# Get session from Redis
|
||||
session = await self.redis_service.get_session(telefono)
|
||||
|
||||
if session:
|
||||
return await self._handle_message_classification(request, session)
|
||||
else:
|
||||
# No session in Redis, check Firestore
|
||||
logger.info(
|
||||
"No session found in Redis. Performing full lookup to Firestore."
|
||||
)
|
||||
return await self._full_lookup_and_process(request, telefono, nickname)
|
||||
|
||||
async def _handle_message_classification(
|
||||
self,
|
||||
request: ExternalConvRequestDTO,
|
||||
session: ConversationSessionDTO,
|
||||
) -> DetectIntentResponseDTO:
|
||||
"""
|
||||
Classify message using MessageEntryFilter and route accordingly.
|
||||
|
||||
Checks for active notifications and uses Gemini to determine if the
|
||||
user's message is about the notification or general conversation.
|
||||
|
||||
Args:
|
||||
request: External request
|
||||
session: Existing conversation session
|
||||
|
||||
Returns:
|
||||
Dialogflow response
|
||||
"""
|
||||
telefono = request.usuario.telefono
|
||||
user_message = request.mensaje
|
||||
|
||||
# Get active notification for this phone
|
||||
notification_id = await self.redis_service.get_notification_id_for_phone(
|
||||
telefono
|
||||
)
|
||||
|
||||
if not notification_id:
|
||||
# No notification, proceed with standard conversation
|
||||
return await self._proceed_with_conversation(request, session)
|
||||
|
||||
# Get notification session
|
||||
notification_session = await self.redis_service.get_notification_session(
|
||||
notification_id
|
||||
)
|
||||
|
||||
if not notification_session or not notification_session.notificaciones:
|
||||
return await self._proceed_with_conversation(request, session)
|
||||
|
||||
# Find most recent active notification
|
||||
active_notification = None
|
||||
for notif in sorted(
|
||||
notification_session.notificaciones,
|
||||
key=lambda n: n.timestampCreacion,
|
||||
reverse=True,
|
||||
):
|
||||
if notif.status == "active":
|
||||
active_notification = notif
|
||||
break
|
||||
|
||||
if not active_notification:
|
||||
return await self._proceed_with_conversation(request, session)
|
||||
|
||||
# Get conversation history from Redis (fast in-memory cache)
|
||||
messages_data = await self.redis_service.get_messages(session.sessionId)
|
||||
# Convert message dicts to ConversationEntryDTO objects
|
||||
conversation_entries = [
|
||||
ConversationEntryDTO.model_validate(msg) for msg in messages_data
|
||||
]
|
||||
conversation_history = self.conversation_mapper.to_text_from_entries(
|
||||
conversation_entries
|
||||
)
|
||||
if not conversation_history:
|
||||
conversation_history = ""
|
||||
|
||||
# Classify message using MessageEntryFilter (Gemini)
|
||||
notification_text = self.notification_mapper.to_text(active_notification)
|
||||
classification = await self.message_filter.classify_message(
|
||||
query_input_text=user_message,
|
||||
notifications_json=notification_text,
|
||||
conversation_json=conversation_history,
|
||||
)
|
||||
|
||||
logger.info(f"Message classified as: {classification}")
|
||||
|
||||
if classification == self.message_filter.CATEGORY_NOTIFICATION:
|
||||
# Route to notification conversation flow
|
||||
return await self._start_notification_conversation(
|
||||
request, active_notification, session, conversation_entries
|
||||
)
|
||||
else:
|
||||
# Route to standard conversation flow
|
||||
return await self._proceed_with_conversation(request, session)
|
||||
|
||||
async def _proceed_with_conversation(
|
||||
self,
|
||||
request: ExternalConvRequestDTO,
|
||||
session: ConversationSessionDTO,
|
||||
) -> DetectIntentResponseDTO:
|
||||
"""
|
||||
Proceed with standard Dialogflow conversation.
|
||||
|
||||
Checks session age:
|
||||
- If < 30 minutes: Continue with existing session
|
||||
- If >= 30 minutes: Create new session and inject conversation history
|
||||
|
||||
Args:
|
||||
request: External request
|
||||
session: Existing session
|
||||
|
||||
Returns:
|
||||
Dialogflow response
|
||||
"""
|
||||
datetime.now()
|
||||
|
||||
# Check session age
|
||||
if self._is_session_valid(session):
|
||||
logger.info(
|
||||
f"Recent Session Found: Session {session.sessionId} is within "
|
||||
f"the {self.SESSION_RESET_THRESHOLD_MINUTES}-minute threshold. "
|
||||
f"Proceeding to Dialogflow."
|
||||
)
|
||||
return await self._process_dialogflow_request(
|
||||
session, request, is_new_session=False
|
||||
)
|
||||
else:
|
||||
# Session expired, create new session with history injection
|
||||
logger.info(
|
||||
f"Old Session Found: Session {session.sessionId} is older than "
|
||||
f"the {self.SESSION_RESET_THRESHOLD_MINUTES}-minute threshold."
|
||||
)
|
||||
|
||||
# Create new session
|
||||
new_session_id = SessionIdGenerator.generate()
|
||||
telefono = request.usuario.telefono
|
||||
nickname = (
|
||||
request.usuario.nickname
|
||||
if hasattr(request.usuario, "nickname")
|
||||
else None
|
||||
)
|
||||
user_id = nickname or telefono
|
||||
|
||||
new_session = ConversationSessionDTO.create(
|
||||
session_id=new_session_id,
|
||||
user_id=user_id,
|
||||
telefono=telefono,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Creating new session {new_session_id} from old session "
|
||||
f"{session.sessionId} due to timeout."
|
||||
)
|
||||
|
||||
# Get conversation history from old session
|
||||
old_entries = await self.firestore_service.get_entries(
|
||||
session.sessionId,
|
||||
limit=self.settings.conversation_context_message_limit,
|
||||
)
|
||||
|
||||
# Apply limits (30 days / 60 messages / 50KB)
|
||||
conversation_history = self.conversation_mapper.to_text_with_limits(
|
||||
session, old_entries
|
||||
)
|
||||
|
||||
# Build request with history parameter
|
||||
dialogflow_request = self._build_dialogflow_request(
|
||||
request, new_session, request.mensaje
|
||||
)
|
||||
dialogflow_request.query_params.parameters[self.CONV_HISTORY_PARAM] = (
|
||||
conversation_history
|
||||
)
|
||||
|
||||
return await self._process_dialogflow_request(
|
||||
new_session,
|
||||
request,
|
||||
is_new_session=True,
|
||||
dialogflow_request=dialogflow_request,
|
||||
)
|
||||
|
||||
async def _start_notification_conversation(
|
||||
self,
|
||||
request: ExternalConvRequestDTO,
|
||||
notification: any,
|
||||
session: ConversationSessionDTO,
|
||||
conversation_entries: list[ConversationEntryDTO],
|
||||
) -> DetectIntentResponseDTO:
|
||||
"""
|
||||
Start notification-driven conversation.
|
||||
|
||||
Uses NotificationContextResolver (Gemini) to determine if the question
|
||||
can be answered directly from notification metadata or should be
|
||||
delegated to Dialogflow.
|
||||
|
||||
Args:
|
||||
request: External request
|
||||
notification: Active notification
|
||||
session: Conversation session
|
||||
conversation_entries: Recent conversation history
|
||||
|
||||
Returns:
|
||||
Dialogflow response
|
||||
"""
|
||||
user_message = request.mensaje
|
||||
telefono = request.usuario.telefono
|
||||
|
||||
# Prepare context for NotificationContextResolver
|
||||
self.notification_mapper.to_text(notification)
|
||||
notification_json = self.notification_mapper.to_json(notification)
|
||||
conversation_history = self.conversation_mapper.to_text_from_entries(
|
||||
conversation_entries
|
||||
)
|
||||
|
||||
# Convert notification parameters to metadata string
|
||||
# Filter to only include parameters starting with "notification_po_"
|
||||
metadata = ""
|
||||
if notification.parametros:
|
||||
import json
|
||||
|
||||
filtered_params = {
|
||||
key: value
|
||||
for key, value in notification.parametros.items()
|
||||
if key.startswith("notification_po_")
|
||||
}
|
||||
metadata = json.dumps(filtered_params, ensure_ascii=False)
|
||||
|
||||
# Resolve context using Gemini
|
||||
resolution = await self.notification_context_resolver.resolve_context(
|
||||
query_input_text=user_message,
|
||||
notifications_json=notification_json,
|
||||
conversation_json=conversation_history,
|
||||
metadata=metadata,
|
||||
user_id=session.userId,
|
||||
session_id=session.sessionId,
|
||||
user_phone_number=telefono,
|
||||
)
|
||||
|
||||
if resolution == self.notification_context_resolver.CATEGORY_DIALOGFLOW:
|
||||
# Delegate to Dialogflow
|
||||
logger.info(
|
||||
"NotificationContextResolver returned DIALOGFLOW. Sending to Dialogflow."
|
||||
)
|
||||
|
||||
dialogflow_request = self._build_dialogflow_request(
|
||||
request, session, user_message
|
||||
)
|
||||
|
||||
# Check if session is older than 30 minutes
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
time_diff = datetime.now() - session.lastModified
|
||||
if time_diff >= timedelta(minutes=self.SESSION_RESET_THRESHOLD_MINUTES):
|
||||
# Session is old, inject conversation history
|
||||
logger.info(
|
||||
f"Session is older than {self.SESSION_RESET_THRESHOLD_MINUTES} minutes. "
|
||||
"Injecting conversation history."
|
||||
)
|
||||
# Get conversation history with limits
|
||||
firestore_entries = await self.firestore_service.get_entries(
|
||||
session.sessionId
|
||||
)
|
||||
conversation_history = self.conversation_mapper.to_text_with_limits(
|
||||
session, firestore_entries
|
||||
)
|
||||
dialogflow_request.query_params.parameters[self.CONV_HISTORY_PARAM] = (
|
||||
conversation_history
|
||||
)
|
||||
|
||||
# Always add notification parameters
|
||||
if notification.parametros:
|
||||
dialogflow_request.query_params.parameters.update(notification.parametros)
|
||||
|
||||
response = await self.dialogflow_client.detect_intent(
|
||||
session.sessionId, dialogflow_request
|
||||
)
|
||||
|
||||
await self._persist_conversation_turn(session, user_message, response)
|
||||
return response
|
||||
else:
|
||||
# LLM provided direct answer
|
||||
logger.info(
|
||||
"NotificationContextResolver provided direct answer. Storing in Redis."
|
||||
)
|
||||
|
||||
# Store LLM response in Redis with UUID
|
||||
llm_uuid = str(uuid.uuid4())
|
||||
await self.llm_response_tuner.set_value(llm_uuid, resolution)
|
||||
|
||||
# Send LLM_RESPONSE_PROCESSED event to Dialogflow
|
||||
event_params = {"uuid": llm_uuid}
|
||||
|
||||
response = await self.dialogflow_client.detect_intent_event(
|
||||
session_id=session.sessionId,
|
||||
event_name="LLM_RESPONSE_PROCESSED",
|
||||
parameters=event_params,
|
||||
language_code=self.settings.dialogflow_default_language,
|
||||
)
|
||||
|
||||
# Persist LLM turn
|
||||
await self._persist_llm_turn(session, user_message, resolution)
|
||||
|
||||
return response
|
||||
|
||||
async def _full_lookup_and_process(
|
||||
self,
|
||||
request: ExternalConvRequestDTO,
|
||||
telefono: str,
|
||||
nickname: str | None,
|
||||
) -> DetectIntentResponseDTO:
|
||||
"""
|
||||
Perform full lookup from Firestore and process conversation.
|
||||
|
||||
Called when session is not found in Redis.
|
||||
|
||||
Args:
|
||||
request: External request
|
||||
telefono: User phone number
|
||||
nickname: User nickname
|
||||
|
||||
Returns:
|
||||
Dialogflow response
|
||||
"""
|
||||
# Try Firestore (by phone number)
|
||||
session = await self.firestore_service.get_session_by_phone(telefono)
|
||||
|
||||
if session:
|
||||
# Get conversation history
|
||||
old_entries = await self.firestore_service.get_entries(
|
||||
session.sessionId,
|
||||
limit=self.settings.conversation_context_message_limit,
|
||||
)
|
||||
|
||||
# Create new session with history injection
|
||||
new_session_id = SessionIdGenerator.generate()
|
||||
user_id = nickname or telefono
|
||||
|
||||
new_session = ConversationSessionDTO.create(
|
||||
session_id=new_session_id,
|
||||
user_id=user_id,
|
||||
telefono=telefono,
|
||||
)
|
||||
|
||||
logger.info(f"Creating new session {new_session_id} after full lookup.")
|
||||
|
||||
# Apply history limits
|
||||
conversation_history = self.conversation_mapper.to_text_with_limits(
|
||||
session, old_entries
|
||||
)
|
||||
|
||||
# Build request with history
|
||||
dialogflow_request = self._build_dialogflow_request(
|
||||
request, new_session, request.mensaje
|
||||
)
|
||||
dialogflow_request.query_params.parameters[self.CONV_HISTORY_PARAM] = (
|
||||
conversation_history
|
||||
)
|
||||
|
||||
return await self._process_dialogflow_request(
|
||||
new_session,
|
||||
request,
|
||||
is_new_session=True,
|
||||
dialogflow_request=dialogflow_request,
|
||||
)
|
||||
else:
|
||||
# No session found, create brand new session
|
||||
logger.info(
|
||||
f"No existing session found for {telefono}. Creating new session."
|
||||
)
|
||||
return await self._create_new_session_and_process(
|
||||
request, telefono, nickname
|
||||
)
|
||||
|
||||
async def _create_new_session_and_process(
|
||||
self,
|
||||
request: ExternalConvRequestDTO,
|
||||
telefono: str,
|
||||
nickname: str | None,
|
||||
) -> DetectIntentResponseDTO:
|
||||
"""Create brand new session and process request."""
|
||||
session_id = SessionIdGenerator.generate()
|
||||
user_id = nickname or telefono
|
||||
|
||||
session = ConversationSessionDTO.create(
|
||||
session_id=session_id,
|
||||
user_id=user_id,
|
||||
telefono=telefono,
|
||||
)
|
||||
|
||||
# Save to Redis and Firestore
|
||||
await self.redis_service.save_session(session)
|
||||
await self.firestore_service.save_session(session)
|
||||
|
||||
logger.info(f"Created new session: {session_id} for phone: {telefono}")
|
||||
|
||||
return await self._process_dialogflow_request(
|
||||
session, request, is_new_session=True
|
||||
)
|
||||
|
||||
async def _process_dialogflow_request(
|
||||
self,
|
||||
session: ConversationSessionDTO,
|
||||
request: ExternalConvRequestDTO,
|
||||
is_new_session: bool,
|
||||
dialogflow_request: DetectIntentRequestDTO | None = None,
|
||||
) -> DetectIntentResponseDTO:
|
||||
"""
|
||||
Process Dialogflow request and persist conversation turn.
|
||||
|
||||
Args:
|
||||
session: Conversation session
|
||||
request: External request
|
||||
is_new_session: Whether this is a new session
|
||||
dialogflow_request: Pre-built Dialogflow request (optional)
|
||||
|
||||
Returns:
|
||||
Dialogflow response
|
||||
"""
|
||||
# Build request if not provided
|
||||
if not dialogflow_request:
|
||||
dialogflow_request = self._build_dialogflow_request(
|
||||
request, session, request.mensaje
|
||||
)
|
||||
|
||||
# Call Dialogflow
|
||||
response = await self.dialogflow_client.detect_intent(
|
||||
session.sessionId, dialogflow_request
|
||||
)
|
||||
|
||||
# Persist conversation turn
|
||||
await self._persist_conversation_turn(session, request.mensaje, response)
|
||||
|
||||
logger.info(
|
||||
f"Successfully processed conversation for session: {session.sessionId}"
|
||||
)
|
||||
return response
|
||||
|
||||
def _is_session_valid(self, session: ConversationSessionDTO) -> bool:
|
||||
"""Check if session is within 30-minute threshold."""
|
||||
if not session.lastModified:
|
||||
return False
|
||||
|
||||
time_diff = datetime.now() - session.lastModified
|
||||
return time_diff < timedelta(minutes=self.SESSION_RESET_THRESHOLD_MINUTES)
|
||||
|
||||
def _build_dialogflow_request(
|
||||
self,
|
||||
external_request: ExternalConvRequestDTO,
|
||||
session: ConversationSessionDTO,
|
||||
message: str,
|
||||
) -> DetectIntentRequestDTO:
|
||||
"""Build Dialogflow detect intent request."""
|
||||
# Build query input
|
||||
query_input = QueryInputDTO(
|
||||
text=TextInputDTO(text=message),
|
||||
language_code=self.settings.dialogflow_default_language,
|
||||
)
|
||||
|
||||
# Build query parameters with session context
|
||||
parameters = {
|
||||
"telefono": session.telefono,
|
||||
"usuario_id": session.userId,
|
||||
}
|
||||
|
||||
# Add pantalla_contexto if present
|
||||
if session.pantallaContexto:
|
||||
parameters["pantalla_contexto"] = session.pantallaContexto
|
||||
|
||||
query_params = QueryParamsDTO(parameters=parameters)
|
||||
|
||||
return DetectIntentRequestDTO(
|
||||
query_input=query_input,
|
||||
query_params=query_params,
|
||||
)
|
||||
|
||||
async def _persist_conversation_turn(
|
||||
self,
|
||||
session: ConversationSessionDTO,
|
||||
user_message: str,
|
||||
response: DetectIntentResponseDTO,
|
||||
) -> None:
|
||||
"""
|
||||
Persist conversation turn using reactive write-back pattern.
|
||||
Saves to Redis first, then async to Firestore.
|
||||
"""
|
||||
try:
|
||||
# Update session with last message
|
||||
updated_session = ConversationSessionDTO(
|
||||
**session.model_dump(),
|
||||
lastMessage=user_message,
|
||||
lastModified=datetime.now(),
|
||||
)
|
||||
|
||||
# Create conversation entry
|
||||
response_text = ""
|
||||
intent = None
|
||||
parameters = None
|
||||
|
||||
if response.queryResult:
|
||||
response_text = response.queryResult.text or ""
|
||||
intent = response.queryResult.intent
|
||||
parameters = response.queryResult.parameters
|
||||
|
||||
user_entry = ConversationEntryDTO(
|
||||
entity="USUARIO",
|
||||
type="CONVERSACION",
|
||||
timestamp=datetime.now(),
|
||||
text=user_message,
|
||||
parameters=None,
|
||||
intent=None,
|
||||
)
|
||||
|
||||
agent_entry = ConversationEntryDTO(
|
||||
entity="AGENTE",
|
||||
type="CONVERSACION",
|
||||
timestamp=datetime.now(),
|
||||
text=response_text,
|
||||
parameters=parameters,
|
||||
intent=intent,
|
||||
)
|
||||
|
||||
# Save to Redis (fast, blocking)
|
||||
await self.redis_service.save_session(updated_session)
|
||||
await self.redis_service.save_message(session.sessionId, user_entry)
|
||||
await self.redis_service.save_message(session.sessionId, agent_entry)
|
||||
|
||||
# Save to Firestore (persistent, non-blocking write-back)
|
||||
import asyncio
|
||||
|
||||
async def save_to_firestore():
|
||||
try:
|
||||
await self.firestore_service.save_session(updated_session)
|
||||
await self.firestore_service.save_entry(session.sessionId, user_entry)
|
||||
await self.firestore_service.save_entry(session.sessionId, agent_entry)
|
||||
logger.debug(
|
||||
f"Asynchronously (Write-Back): Entry successfully saved to Firestore for session: {session.sessionId}"
|
||||
)
|
||||
except Exception as fs_error:
|
||||
logger.error(
|
||||
f"Asynchronously (Write-Back): Failed to save to Firestore for session {session.sessionId}: {str(fs_error)}",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# Fire and forget - don't await
|
||||
asyncio.create_task(save_to_firestore())
|
||||
|
||||
logger.debug(f"Entry saved to Redis for session: {session.sessionId}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error persisting conversation turn for session {session.sessionId}: {str(e)}",
|
||||
exc_info=True,
|
||||
)
|
||||
# Don't fail the request if persistence fails
|
||||
|
||||
async def _persist_llm_turn(
|
||||
self,
|
||||
session: ConversationSessionDTO,
|
||||
user_message: str,
|
||||
llm_response: str,
|
||||
) -> None:
|
||||
"""Persist LLM-generated conversation turn."""
|
||||
try:
|
||||
# Update session
|
||||
updated_session = ConversationSessionDTO(
|
||||
**session.model_dump(),
|
||||
lastMessage=user_message,
|
||||
lastModified=datetime.now(),
|
||||
)
|
||||
|
||||
user_entry = ConversationEntryDTO(
|
||||
entity="USUARIO",
|
||||
type="CONVERSACION",
|
||||
timestamp=datetime.now(),
|
||||
text=user_message,
|
||||
parameters=notification.parametros,
|
||||
intent=None,
|
||||
)
|
||||
|
||||
llm_entry = ConversationEntryDTO(
|
||||
entity="LLM",
|
||||
type="LLM",
|
||||
timestamp=datetime.now(),
|
||||
text=llm_response,
|
||||
parameters=None,
|
||||
intent=None,
|
||||
)
|
||||
|
||||
# Save to Redis (fast, blocking)
|
||||
await self.redis_service.save_session(updated_session)
|
||||
await self.redis_service.save_message(session.sessionId, user_entry)
|
||||
await self.redis_service.save_message(session.sessionId, llm_entry)
|
||||
|
||||
# Save to Firestore (persistent, non-blocking write-back)
|
||||
import asyncio
|
||||
|
||||
async def save_to_firestore():
|
||||
try:
|
||||
await self.firestore_service.save_session(updated_session)
|
||||
await self.firestore_service.save_entry(session.sessionId, user_entry)
|
||||
await self.firestore_service.save_entry(session.sessionId, llm_entry)
|
||||
logger.debug(
|
||||
f"Asynchronously (Write-Back): LLM entry successfully saved to Firestore for session: {session.sessionId}"
|
||||
)
|
||||
except Exception as fs_error:
|
||||
logger.error(
|
||||
f"Asynchronously (Write-Back): Failed to save LLM entry to Firestore for session {session.sessionId}: {str(fs_error)}",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# Fire and forget - don't await
|
||||
asyncio.create_task(save_to_firestore())
|
||||
|
||||
logger.debug(f"LLM entry saved to Redis for session: {session.sessionId}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error persisting LLM turn for session {session.sessionId}: {str(e)}",
|
||||
exc_info=True,
|
||||
)
|
||||
Reference in New Issue
Block a user