Files
int-layer/src/capa_de_integracion/services/conversation_manager.py
2026-02-20 08:42:45 +00:00

848 lines
30 KiB
Python

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