""" 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, )