848 lines
30 KiB
Python
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,
|
|
)
|