This commit is contained in:
2026-02-19 17:50:14 +00:00
parent da95a64fb7
commit 6f629c53a6
171 changed files with 7281 additions and 1144 deletions

View File

@@ -0,0 +1,11 @@
"""
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.
Capa de Integración - Conversational AI Orchestrator Service
"""
from .main import main, app
__all__ = ["main", "app"]

View File

@@ -0,0 +1,5 @@
"""Configuration module."""
from .settings import Settings, get_settings
__all__ = ["Settings", "get_settings"]

View File

@@ -0,0 +1,113 @@
"""
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.
Application configuration settings.
"""
from functools import lru_cache
from pathlib import Path
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
"""Application configuration from environment variables."""
model_config = SettingsConfigDict(
env_file=".env", env_file_encoding="utf-8", case_sensitive=False
)
# GCP General
gcp_project_id: str = Field(..., alias="GCP_PROJECT_ID")
gcp_location: str = Field(default="us-central1", alias="GCP_LOCATION")
# Firestore
firestore_database_id: str = Field(..., alias="GCP_FIRESTORE_DATABASE_ID")
firestore_host: str = Field(
default="firestore.googleapis.com", alias="GCP_FIRESTORE_HOST"
)
firestore_port: int = Field(default=443, alias="GCP_FIRESTORE_PORT")
firestore_importer_enabled: bool = Field(
default=False, alias="GCP_FIRESTORE_IMPORTER_ENABLE"
)
# Redis
redis_host: str = Field(..., alias="REDIS_HOST")
redis_port: int = Field(default=6379, alias="REDIS_PORT")
redis_password: str | None = Field(default=None, alias="REDIS_PWD")
redis_ssl: bool = Field(default=False)
# Dialogflow CX
dialogflow_project_id: str = Field(..., alias="DIALOGFLOW_CX_PROJECT_ID")
dialogflow_location: str = Field(..., alias="DIALOGFLOW_CX_LOCATION")
dialogflow_agent_id: str = Field(..., alias="DIALOGFLOW_CX_AGENT_ID")
dialogflow_default_language: str = Field(
default="es", alias="DIALOGFLOW_DEFAULT_LANGUAGE_CODE"
)
# Gemini
gemini_model_name: str = Field(
default="gemini-2.0-flash-001", alias="GEMINI_MODEL_NAME"
)
# Message Filter (Gemini)
message_filter_model: str = Field(
default="gemini-2.0-flash-001", alias="MESSAGE_FILTER_GEMINI_MODEL"
)
message_filter_temperature: float = Field(
default=0.1, alias="MESSAGE_FILTER_TEMPERATURE"
)
message_filter_max_tokens: int = Field(
default=10, alias="MESSAGE_FILTER_MAX_OUTPUT_TOKENS"
)
message_filter_top_p: float = Field(default=0.1, alias="MESSAGE_FILTER_TOP_P")
message_filter_prompt_path: str = Field(default="prompts/message_filter_prompt.txt")
# Notification Context Resolver (Gemini)
notification_context_model: str = Field(
default="gemini-2.0-flash-001", alias="NOTIFICATION_CONTEXT_GEMINI_MODEL"
)
notification_context_temperature: float = Field(
default=0.1, alias="NOTIFICATION_CONTEXT_TEMPERATURE"
)
notification_context_max_tokens: int = Field(
default=1024, alias="NOTIFICATION_CONTEXT_MAX_OUTPUT_TOKENS"
)
notification_context_top_p: float = Field(
default=0.1, alias="NOTIFICATION_CONTEXT_TOP_P"
)
notification_context_prompt_path: str = Field(
default="prompts/notification_context_resolver.txt"
)
# DLP
dlp_template_complete_flow: str = Field(..., alias="DLP_TEMPLATE_COMPLETE_FLOW")
# Conversation Context
conversation_context_message_limit: int = Field(
default=60, alias="CONVERSATION_CONTEXT_MESSAGE_LIMIT"
)
conversation_context_days_limit: int = Field(
default=30, alias="CONVERSATION_CONTEXT_DAYS_LIMIT"
)
# Logging
log_level: str = Field(default="INFO", alias="LOGGING_LEVEL_ROOT")
@property
def dialogflow_endpoint(self) -> str:
"""Get Dialogflow regional endpoint."""
return f"{self.dialogflow_location}-dialogflow.googleapis.com:443"
@property
def base_path(self) -> Path:
"""Get base path for resources."""
return Path(__file__).parent.parent / "resources"
@lru_cache
def get_settings() -> Settings:
"""Get cached settings instance."""
return Settings()

View File

@@ -0,0 +1,15 @@
"""Controllers module."""
from .conversation import router as conversation_router
from .notification import router as notification_router
from .llm_webhook import router as llm_webhook_router
from .quick_replies import router as quick_replies_router
from .data_purge import router as data_purge_router
__all__ = [
"conversation_router",
"notification_router",
"llm_webhook_router",
"quick_replies_router",
"data_purge_router",
]

View File

@@ -0,0 +1,49 @@
"""
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 API endpoints.
"""
import logging
from fastapi import APIRouter, Depends, HTTPException
from ..models import ExternalConvRequestDTO, DetectIntentResponseDTO
from ..services import ConversationManagerService
from ..dependencies import get_conversation_manager
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1/dialogflow", tags=["conversation"])
@router.post("/detect-intent", response_model=DetectIntentResponseDTO)
async def detect_intent(
request: ExternalConvRequestDTO,
conversation_manager: ConversationManagerService = Depends(
get_conversation_manager
),
) -> DetectIntentResponseDTO:
"""
Detect user intent and manage conversation.
Args:
request: External conversation request from client
Returns:
Dialogflow detect intent response
"""
try:
logger.info("Received detect-intent request")
response = await conversation_manager.manage_conversation(request)
logger.info("Successfully processed detect-intent request")
return response
except ValueError as e:
logger.error(f"Validation error: {str(e)}", exc_info=True)
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"Error processing detect-intent: {str(e)}", exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")

View File

@@ -0,0 +1,44 @@
"""
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.
Data purge API endpoints.
"""
import logging
from fastapi import APIRouter, Depends, HTTPException
from ..services.data_purge import DataPurgeService
from ..services.redis_service import RedisService
from ..dependencies import get_redis_service, get_settings
from ..config import Settings
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1/data-purge", tags=["data-purge"])
@router.delete("/all")
async def purge_all_data(
redis_service: RedisService = Depends(get_redis_service),
settings: Settings = Depends(get_settings),
) -> None:
"""
Purge all data from Redis and Firestore.
WARNING: This is a destructive operation that will delete all conversation
and notification data from both Redis and Firestore.
"""
logger.warning(
"Received request to purge all data. This is a destructive operation."
)
try:
purge_service = DataPurgeService(settings, redis_service)
await purge_service.purge_all_data()
await purge_service.close()
logger.info("Successfully purged all data")
except Exception as e:
logger.error(f"Error purging all data: {str(e)}", exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")

View File

@@ -0,0 +1,99 @@
"""
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.
LLM webhook API endpoints for Dialogflow CX integration.
"""
import logging
from fastapi import APIRouter, Depends
from ..models.llm_webhook import WebhookRequestDTO, WebhookResponseDTO, SessionInfoDTO
from ..services.llm_response_tuner import LlmResponseTunerService
from ..dependencies import get_llm_response_tuner
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1/llm", tags=["llm-webhook"])
@router.post("/tune-response", response_model=WebhookResponseDTO)
async def tune_response(
request: WebhookRequestDTO,
llm_tuner: LlmResponseTunerService = Depends(get_llm_response_tuner),
) -> WebhookResponseDTO:
"""
Dialogflow CX webhook to retrieve pre-generated LLM responses.
This endpoint is called by Dialogflow when an intent requires an
LLM-generated response. The UUID is passed in session parameters,
and the service retrieves the pre-computed response from Redis.
Flow:
1. Dialogflow sends webhook request with UUID in parameters
2. Service retrieves response from Redis (1-hour TTL)
3. Returns response in session parameters
Args:
request: Webhook request containing session info with UUID
Returns:
Webhook response with response text or error
Raises:
HTTPException: 400 for validation errors, 500 for internal errors
"""
try:
# Extract UUID from session parameters
uuid = request.sessionInfo.parameters.get("uuid")
if not uuid:
logger.error("No UUID provided in webhook request")
return _create_error_response("UUID parameter is required", is_error=True)
# Retrieve response from Redis
response_text = await llm_tuner.get_value(uuid)
if response_text:
# Success - return response
logger.info(f"Successfully retrieved response for UUID: {uuid}")
return WebhookResponseDTO(
sessionInfo=SessionInfoDTO(
parameters={
"webhook_success": True,
"response": response_text,
}
)
)
else:
# Not found
logger.warning(f"No response found for UUID: {uuid}")
return _create_error_response(
"No response found for the given UUID.", is_error=False
)
except Exception as e:
logger.error(f"Error in tune-response webhook: {e}", exc_info=True)
return _create_error_response("An internal error occurred.", is_error=True)
def _create_error_response(error_message: str, is_error: bool) -> WebhookResponseDTO:
"""
Create error response for webhook.
Args:
error_message: Error message to return
is_error: Whether this is a critical error
Returns:
Webhook response with error info
"""
return WebhookResponseDTO(
sessionInfo=SessionInfoDTO(
parameters={
"webhook_success": False,
"error_message": error_message,
}
)
)

View File

@@ -0,0 +1,61 @@
"""
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.
Notification API endpoints.
"""
import logging
from fastapi import APIRouter, Depends, HTTPException
from ..models.notification import ExternalNotRequestDTO
from ..services.notification_manager import NotificationManagerService
from ..dependencies import get_notification_manager
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1/dialogflow", tags=["notifications"])
@router.post("/notification", status_code=200)
async def process_notification(
request: ExternalNotRequestDTO,
notification_manager: NotificationManagerService = Depends(
get_notification_manager
),
) -> None:
"""
Process push notification from external system.
This endpoint receives notifications (e.g., "Your card was blocked") and:
1. Stores them in Redis/Firestore
2. Associates them with the user's conversation session
3. Triggers a Dialogflow event
When the user later sends a message asking about the notification
("Why was it blocked?"), the message filter will classify it as
NOTIFICATION and route to the appropriate handler.
Args:
request: External notification request with text, phone, and parameters
Returns:
None (204 No Content)
Raises:
HTTPException: 400 if validation fails, 500 for internal errors
"""
try:
logger.info("Received notification request")
await notification_manager.process_notification(request)
logger.info("Successfully processed notification request")
# Match Java behavior: process but don't return response body
except ValueError as e:
logger.error(f"Validation error: {str(e)}", exc_info=True)
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"Error processing notification: {str(e)}", exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")

View File

@@ -0,0 +1,112 @@
"""
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.
Quick Replies API endpoints.
"""
import logging
from fastapi import APIRouter, Depends, HTTPException
from ..models.quick_replies import QuickReplyScreenRequestDTO
from ..models import DetectIntentResponseDTO
from ..services.quick_reply_content import QuickReplyContentService
from ..services.redis_service import RedisService
from ..services.firestore_service import FirestoreService
from ..utils.session_id import generate_session_id
from ..models.conversation import ConversationSessionDTO, ConversationEntryDTO
from ..dependencies import get_redis_service, get_firestore_service, get_settings
from ..config import Settings
from datetime import datetime
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1/quick-replies", tags=["quick-replies"])
@router.post("/screen", response_model=DetectIntentResponseDTO)
async def start_quick_reply_session(
request: QuickReplyScreenRequestDTO,
redis_service: RedisService = Depends(get_redis_service),
firestore_service: FirestoreService = Depends(get_firestore_service),
settings: Settings = Depends(get_settings),
) -> DetectIntentResponseDTO:
"""
Start a quick reply FAQ session for a specific screen.
Creates a conversation session with pantalla_contexto set,
loads the quick reply questions for the screen, and returns them.
Args:
request: Quick reply screen request
Returns:
Detect intent response with quick reply questions
"""
try:
telefono = request.telefono
if not telefono or not telefono.strip():
raise ValueError("Phone number is required")
# Generate session ID
session_id = generate_session_id()
user_id = f"user_by_phone_{telefono.replace(' ', '').replace('-', '')}"
# Create system entry
system_entry = ConversationEntryDTO(
entity="SISTEMA",
type="INICIO",
timestamp=datetime.now(),
text=f"Pantalla: {request.pantalla_contexto} Agregada a la conversacion",
parameters=None,
intent=None,
)
# Create new session with pantalla_contexto
new_session = ConversationSessionDTO(
sessionId=session_id,
userId=user_id,
telefono=telefono,
createdAt=datetime.now(),
lastModified=datetime.now(),
lastMessage=system_entry.text,
pantallaContexto=request.pantalla_contexto,
)
# Save session and entry
await redis_service.save_session(new_session)
logger.info(
f"Created quick reply session {session_id} for screen: {request.pantalla_contexto}"
)
# Load quick replies
content_service = QuickReplyContentService(settings)
quick_reply_dto = await content_service.get_quick_replies(
request.pantalla_contexto
)
if not quick_reply_dto:
raise ValueError(
f"Quick reply screen not found: {request.pantalla_contexto}"
)
# Background save to Firestore
try:
await firestore_service.save_session(new_session)
await firestore_service.save_entry(session_id, system_entry)
except Exception as e:
logger.error(f"Background Firestore save failed: {e}")
return DetectIntentResponseDTO(
responseId=session_id,
queryResult=None,
quick_replies=quick_reply_dto,
)
except ValueError as e:
logger.error(f"Validation error: {e}", exc_info=True)
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"Error starting quick reply session: {e}", exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")

View File

@@ -0,0 +1,196 @@
"""
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.
FastAPI dependency injection.
"""
from .config import get_settings, Settings
from .services import (
DialogflowClientService,
GeminiClientService,
ConversationManagerService,
MessageEntryFilter,
NotificationManagerService,
NotificationContextResolver,
DLPService,
LlmResponseTunerService,
)
from .services.redis_service import RedisService
from .services.firestore_service import FirestoreService
# Global service instances (initialized at startup)
_dialogflow_client: DialogflowClientService | None = None
_gemini_client: GeminiClientService | None = None
_message_filter: MessageEntryFilter | None = None
_notification_context_resolver: NotificationContextResolver | None = None
_dlp_service: DLPService | None = None
_redis_service: RedisService | None = None
_firestore_service: FirestoreService | None = None
_conversation_manager: ConversationManagerService | None = None
_notification_manager: NotificationManagerService | None = None
_llm_response_tuner: LlmResponseTunerService | None = None
def init_services(settings: Settings):
"""Initialize all services at startup."""
global \
_dialogflow_client, \
_gemini_client, \
_message_filter, \
_notification_context_resolver, \
_dlp_service, \
_redis_service, \
_firestore_service, \
_conversation_manager, \
_notification_manager, \
_llm_response_tuner
_dialogflow_client = DialogflowClientService(settings)
_gemini_client = GeminiClientService(settings)
_message_filter = MessageEntryFilter(settings, _gemini_client)
_notification_context_resolver = NotificationContextResolver(
settings, _gemini_client
)
_dlp_service = DLPService(settings)
_redis_service = RedisService(settings)
_firestore_service = FirestoreService(settings)
# Note: LlmResponseTunerService will be initialized after Redis connects
_llm_response_tuner = None
# Initialize notification manager (without llm_response_tuner)
_notification_manager = NotificationManagerService(
settings=settings,
dialogflow_client=_dialogflow_client,
redis_service=_redis_service,
firestore_service=_firestore_service,
dlp_service=_dlp_service,
)
# Note: ConversationManagerService will be fully initialized after Redis connects
# For now, initialize with placeholder for llm_response_tuner
_conversation_manager = None
async def startup_services():
"""Connect services at startup."""
global \
_redis_service, \
_llm_response_tuner, \
_conversation_manager, \
_dialogflow_client, \
_message_filter, \
_notification_context_resolver, \
_dlp_service, \
_firestore_service
settings = get_settings()
if _redis_service:
await _redis_service.connect()
# Initialize LLM Response Tuner after Redis connects
if _redis_service.redis:
_llm_response_tuner = LlmResponseTunerService(_redis_service.redis)
# Now initialize ConversationManagerService with all dependencies
_conversation_manager = ConversationManagerService(
settings=settings,
dialogflow_client=_dialogflow_client,
redis_service=_redis_service,
firestore_service=_firestore_service,
dlp_service=_dlp_service,
message_filter=_message_filter,
notification_context_resolver=_notification_context_resolver,
llm_response_tuner=_llm_response_tuner,
)
async def shutdown_services():
"""Clean up services at shutdown."""
global _dialogflow_client, _redis_service, _firestore_service, _dlp_service
if _dialogflow_client:
await _dialogflow_client.close()
if _redis_service:
await _redis_service.close()
if _firestore_service:
await _firestore_service.close()
if _dlp_service:
await _dlp_service.close()
def get_conversation_manager() -> ConversationManagerService:
"""Get conversation manager instance."""
if _conversation_manager is None:
raise RuntimeError("Services not initialized. Call init_services first.")
return _conversation_manager
def get_dialogflow_client() -> DialogflowClientService:
"""Get Dialogflow client instance."""
if _dialogflow_client is None:
raise RuntimeError("Services not initialized. Call init_services first.")
return _dialogflow_client
def get_redis_service() -> RedisService:
"""Get Redis service instance."""
if _redis_service is None:
raise RuntimeError("Services not initialized. Call init_services first.")
return _redis_service
def get_firestore_service() -> FirestoreService:
"""Get Firestore service instance."""
if _firestore_service is None:
raise RuntimeError("Services not initialized. Call init_services first.")
return _firestore_service
def get_gemini_client() -> GeminiClientService:
"""Get Gemini client instance."""
if _gemini_client is None:
raise RuntimeError("Services not initialized. Call init_services first.")
return _gemini_client
def get_message_filter() -> MessageEntryFilter:
"""Get message filter instance."""
if _message_filter is None:
raise RuntimeError("Services not initialized. Call init_services first.")
return _message_filter
def get_notification_manager() -> NotificationManagerService:
"""Get notification manager instance."""
if _notification_manager is None:
raise RuntimeError("Services not initialized. Call init_services first.")
return _notification_manager
def get_dlp_service() -> DLPService:
"""Get DLP service instance."""
if _dlp_service is None:
raise RuntimeError("Services not initialized. Call init_services first.")
return _dlp_service
def get_notification_context_resolver() -> NotificationContextResolver:
"""Get notification context resolver instance."""
if _notification_context_resolver is None:
raise RuntimeError("Services not initialized. Call init_services first.")
return _notification_context_resolver
def get_llm_response_tuner() -> LlmResponseTunerService:
"""Get LLM response tuner instance."""
if _llm_response_tuner is None:
raise RuntimeError("Services not initialized. Call startup_services first.")
return _llm_response_tuner

View File

@@ -0,0 +1,102 @@
"""
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.
Main FastAPI application.
"""
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from .config import get_settings
from .controllers import (
conversation_router,
notification_router,
llm_webhook_router,
quick_replies_router,
data_purge_router,
)
from .dependencies import init_services, startup_services, shutdown_services
# Configure logging
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
)
logger = logging.getLogger(__name__)
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Application lifespan manager."""
# Startup
settings = get_settings()
logger.info("Initializing services...")
init_services(settings)
await startup_services()
logger.info("Application started successfully")
yield
# Shutdown
logger.info("Shutting down services...")
await shutdown_services()
logger.info("Application shutdown complete")
def create_app() -> FastAPI:
"""Create and configure FastAPI application."""
app = FastAPI(
title="Capa de Integración - Orchestrator Service",
description="Conversational AI orchestrator for Dialogflow CX, Gemini, and Vertex AI",
version="0.1.0",
lifespan=lifespan,
)
# CORS middleware
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # Configure appropriately for production
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Register routers
app.include_router(conversation_router)
app.include_router(notification_router)
app.include_router(llm_webhook_router)
app.include_router(quick_replies_router)
app.include_router(data_purge_router)
@app.get("/health")
async def health_check():
"""Health check endpoint."""
return {"status": "healthy", "service": "capa-de-integracion"}
return app
# Create app instance
app = create_app()
def main():
"""Entry point for CLI."""
import uvicorn
uvicorn.run(
"capa_de_integracion.main:app",
host="0.0.0.0",
port=8080,
reload=True,
)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,43 @@
"""Data models module."""
from .conversation import (
ConversationSessionDTO,
ConversationEntryDTO,
ConversationMessageDTO,
ExternalConvRequestDTO,
DetectIntentRequestDTO,
DetectIntentResponseDTO,
QueryInputDTO,
TextInputDTO,
EventInputDTO,
QueryParamsDTO,
QueryResultDTO,
MessageType,
ConversationEntryType,
)
from .notification import (
ExternalNotRequestDTO,
NotificationSessionDTO,
NotificationDTO,
)
__all__ = [
# Conversation
"ConversationSessionDTO",
"ConversationEntryDTO",
"ConversationMessageDTO",
"ExternalConvRequestDTO",
"DetectIntentRequestDTO",
"DetectIntentResponseDTO",
"QueryInputDTO",
"TextInputDTO",
"EventInputDTO",
"QueryParamsDTO",
"QueryResultDTO",
"MessageType",
"ConversationEntryType",
# Notification
"ExternalNotRequestDTO",
"NotificationSessionDTO",
"NotificationDTO",
]

View File

@@ -0,0 +1,173 @@
"""
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-related data models.
"""
from datetime import datetime
from enum import Enum
from typing import Any
from pydantic import BaseModel, Field, field_validator
class MessageType(str, Enum):
"""Message type enumeration."""
USER = "USER"
AGENT = "AGENT"
class ConversationEntryType(str, Enum):
"""Conversation entry type enumeration."""
INICIO = "INICIO"
CONVERSACION = "CONVERSACION"
LLM = "LLM"
class UsuarioDTO(BaseModel):
"""User information."""
telefono: str = Field(..., min_length=1)
nickname: str | None = None
class TextInputDTO(BaseModel):
"""Text input for queries."""
text: str
class EventInputDTO(BaseModel):
"""Event input for queries."""
event: str
class QueryParamsDTO(BaseModel):
"""Query parameters for Dialogflow requests."""
parameters: dict[str, Any] | None = None
class QueryInputDTO(BaseModel):
"""Query input combining text or event."""
text: TextInputDTO | None = None
event: EventInputDTO | None = None
language_code: str = "es"
@field_validator("text", "event")
@classmethod
def check_at_least_one(cls, v, info):
"""Ensure either text or event is provided."""
if info.field_name == "event" and v is None:
# Check if text was provided
if not info.data.get("text"):
raise ValueError("Either text or event must be provided")
return v
class DetectIntentRequestDTO(BaseModel):
"""Dialogflow detect intent request."""
query_input: QueryInputDTO
query_params: QueryParamsDTO | None = None
class QueryResultDTO(BaseModel):
"""Query result from Dialogflow."""
responseText: str | None = Field(None, alias="responseText")
parameters: dict[str, Any] | None = Field(None, alias="parameters")
model_config = {"populate_by_name": True}
class DetectIntentResponseDTO(BaseModel):
"""Dialogflow detect intent response."""
responseId: str | None = Field(None, alias="responseId")
queryResult: QueryResultDTO | None = Field(None, alias="queryResult")
quick_replies: Any | None = None # QuickReplyDTO from quick_replies module
model_config = {"populate_by_name": True}
class ExternalConvRequestDTO(BaseModel):
"""External conversation request from client."""
mensaje: str = Field(..., alias="mensaje")
usuario: UsuarioDTO = Field(..., alias="usuario")
canal: str = Field(..., alias="canal")
tipo: ConversationEntryType = Field(..., alias="tipo")
pantalla_contexto: str | None = Field(None, alias="pantallaContexto")
model_config = {"populate_by_name": True}
class ConversationMessageDTO(BaseModel):
"""Single conversation message."""
type: str = Field(..., alias="type") # Maps to MessageType
timestamp: datetime = Field(default_factory=datetime.now, alias="timestamp")
text: str = Field(..., alias="text")
parameters: dict[str, Any] | None = Field(None, alias="parameters")
canal: str | None = Field(None, alias="canal")
model_config = {"populate_by_name": True}
class ConversationEntryDTO(BaseModel):
"""Single conversation entry."""
entity: str = Field(..., alias="entity") # "USUARIO", "AGENTE", "SISTEMA", "LLM"
type: str = Field(..., alias="type") # "INICIO", "CONVERSACION", "LLM"
timestamp: datetime = Field(default_factory=datetime.now, alias="timestamp")
text: str = Field(..., alias="text")
parameters: dict[str, Any] | None = Field(None, alias="parameters")
canal: str | None = Field(None, alias="canal")
model_config = {"populate_by_name": True}
class ConversationSessionDTO(BaseModel):
"""Conversation session metadata."""
sessionId: str = Field(..., alias="sessionId")
userId: str = Field(..., alias="userId")
telefono: str = Field(..., alias="telefono")
createdAt: datetime = Field(default_factory=datetime.now, alias="createdAt")
lastModified: datetime = Field(default_factory=datetime.now, alias="lastModified")
lastMessage: str | None = Field(None, alias="lastMessage")
pantallaContexto: str | None = Field(None, alias="pantallaContexto")
model_config = {"populate_by_name": True}
@classmethod
def create(
cls, session_id: str, user_id: str, telefono: str
) -> "ConversationSessionDTO":
"""Create a new conversation session."""
now = datetime.now()
return cls(
sessionId=session_id,
userId=user_id,
telefono=telefono,
createdAt=now,
lastModified=now,
)
def with_last_message(self, last_message: str) -> "ConversationSessionDTO":
"""Create copy with updated last message."""
return self.model_copy(
update={"lastMessage": last_message, "lastModified": datetime.now()}
)
def with_pantalla_contexto(
self, pantalla_contexto: str
) -> "ConversationSessionDTO":
"""Create copy with updated pantalla contexto."""
return self.model_copy(update={"pantallaContexto": pantalla_contexto})

View File

@@ -0,0 +1,34 @@
"""
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.
LLM webhook data models for Dialogflow CX webhook integration.
"""
from typing import Any
from pydantic import BaseModel, Field
class SessionInfoDTO(BaseModel):
"""Session info containing parameters."""
parameters: dict[str, Any] = Field(default_factory=dict)
class WebhookRequestDTO(BaseModel):
"""Dialogflow CX webhook request."""
sessionInfo: SessionInfoDTO = Field(
default_factory=SessionInfoDTO, alias="sessionInfo"
)
model_config = {"populate_by_name": True}
class WebhookResponseDTO(BaseModel):
"""Dialogflow CX webhook response."""
sessionInfo: SessionInfoDTO = Field(..., alias="sessionInfo")
model_config = {"populate_by_name": True}

View File

@@ -0,0 +1,86 @@
"""
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.
Notification-related data models.
"""
from datetime import datetime
from typing import Any
from pydantic import BaseModel, Field
class NotificationDTO(BaseModel):
"""
Individual notification event record.
Represents a notification to be stored in Firestore and cached in Redis.
"""
idNotificacion: str = Field(
..., alias="idNotificacion", description="Unique notification ID"
)
telefono: str = Field(..., alias="telefono", description="User phone number")
timestampCreacion: datetime = Field(
default_factory=datetime.now,
alias="timestampCreacion",
description="Notification creation timestamp",
)
texto: str = Field(..., alias="texto", description="Notification text content")
nombreEventoDialogflow: str = Field(
default="notificacion",
alias="nombreEventoDialogflow",
description="Dialogflow event name",
)
codigoIdiomaDialogflow: str = Field(
default="es",
alias="codigoIdiomaDialogflow",
description="Dialogflow language code",
)
parametros: dict[str, Any] = Field(
default_factory=dict,
alias="parametros",
description="Session parameters for Dialogflow",
)
status: str = Field(
default="active", alias="status", description="Notification status"
)
model_config = {"populate_by_name": True}
class NotificationSessionDTO(BaseModel):
"""Notification session containing multiple notifications for a phone number."""
sessionId: str = Field(..., alias="sessionId", description="Session identifier")
telefono: str = Field(..., alias="telefono", description="User phone number")
fechaCreacion: datetime = Field(
default_factory=datetime.now,
alias="fechaCreacion",
description="Session creation time",
)
ultimaActualizacion: datetime = Field(
default_factory=datetime.now,
alias="ultimaActualizacion",
description="Last update time",
)
notificaciones: list[NotificationDTO] = Field(
default_factory=list,
alias="notificaciones",
description="List of notification events",
)
model_config = {"populate_by_name": True}
class ExternalNotRequestDTO(BaseModel):
"""External notification push request from client."""
texto: str = Field(..., alias="texto", description="Notification text")
telefono: str = Field(..., alias="telefono", description="User phone number")
parametros_ocultos: dict[str, Any] | None = Field(
None, alias="parametrosOcultos", description="Hidden parameters (metadata)"
)
model_config = {"populate_by_name": True}

View File

@@ -0,0 +1,43 @@
"""
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.
Quick Replies data models.
"""
from pydantic import BaseModel, Field
class QuestionDTO(BaseModel):
"""Individual FAQ question."""
titulo: str
descripcion: str | None = None
respuesta: str
class QuickReplyDTO(BaseModel):
"""Quick reply screen with questions."""
header: str | None = None
body: str | None = None
button: str | None = None
header_section: str | None = None
preguntas: list[QuestionDTO] = Field(default_factory=list)
class QuickReplyScreenRequestDTO(BaseModel):
"""Request to load a quick reply screen."""
usuario: dict = Field(..., alias="usuario")
canal: str = Field(..., alias="canal")
tipo: str = Field(..., alias="tipo")
pantalla_contexto: str = Field(..., alias="pantallaContexto")
model_config = {"populate_by_name": True}
@property
def telefono(self) -> str:
"""Extract phone number from usuario."""
return self.usuario.get("telefono", "")

View File

@@ -0,0 +1,25 @@
"""Services module."""
from .dialogflow_client import DialogflowClientService
from .gemini_client import GeminiClientService, GeminiClientException
from .conversation_manager import ConversationManagerService
from .message_filter import MessageEntryFilter
from .notification_manager import NotificationManagerService
from .notification_context_resolver import NotificationContextResolver
from .dlp_service import DLPService
from .llm_response_tuner import LlmResponseTunerService
from .mappers import NotificationContextMapper, ConversationContextMapper
__all__ = [
"DialogflowClientService",
"GeminiClientService",
"GeminiClientException",
"ConversationManagerService",
"MessageEntryFilter",
"NotificationManagerService",
"NotificationContextResolver",
"DLPService",
"LlmResponseTunerService",
"NotificationContextMapper",
"ConversationContextMapper",
]

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

View File

@@ -0,0 +1,133 @@
"""
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.
Data purge service for Redis and Firestore.
"""
import logging
from google.cloud import firestore
from ..config import Settings
from .redis_service import RedisService
logger = logging.getLogger(__name__)
class DataPurgeService:
"""Service for purging all data from Redis and Firestore."""
def __init__(self, settings: Settings, redis_service: RedisService):
"""Initialize data purge service."""
self.settings = settings
self.redis_service = redis_service
self.db = firestore.AsyncClient(
project=settings.gcp_project_id,
database=settings.firestore_database_id,
)
async def purge_all_data(self) -> None:
"""Purge all data from Redis and Firestore."""
try:
await self._purge_redis()
await self._purge_firestore()
logger.info("Successfully purged all data from Redis and Firestore")
except Exception as e:
logger.error(f"Error purging data: {str(e)}", exc_info=True)
raise
async def _purge_redis(self) -> None:
"""Purge all data from Redis."""
logger.info("Starting Redis data purge")
try:
if not self.redis_service.redis:
raise RuntimeError("Redis client not connected")
await self.redis_service.redis.flushdb()
logger.info("Successfully purged all data from Redis")
except Exception as e:
logger.error(f"Error purging data from Redis: {str(e)}", exc_info=True)
raise
async def _purge_firestore(self) -> None:
"""Purge all data from Firestore."""
logger.info("Starting Firestore data purge")
try:
app_id = self.settings.gcp_project_id
conversations_path = f"artifacts/{app_id}/conversations"
notifications_path = f"artifacts/{app_id}/notifications"
# Delete mensajes subcollections from conversations
logger.info(
f"Deleting 'mensajes' sub-collections from '{conversations_path}'"
)
try:
conversations_ref = self.db.collection(conversations_path)
async for doc in conversations_ref.stream():
mensajes_ref = doc.reference.collection("mensajes")
await self._delete_collection(mensajes_ref, 50)
except Exception as e:
if "NOT_FOUND" in str(e):
logger.warning(
f"Collection '{conversations_path}' not found, skipping"
)
else:
raise
# Delete conversations collection
logger.info(f"Deleting collection: {conversations_path}")
try:
conversations_ref = self.db.collection(conversations_path)
await self._delete_collection(conversations_ref, 50)
except Exception as e:
if "NOT_FOUND" in str(e):
logger.warning(
f"Collection '{conversations_path}' not found, skipping"
)
else:
raise
# Delete notifications collection
logger.info(f"Deleting collection: {notifications_path}")
try:
notifications_ref = self.db.collection(notifications_path)
await self._delete_collection(notifications_ref, 50)
except Exception as e:
if "NOT_FOUND" in str(e):
logger.warning(
f"Collection '{notifications_path}' not found, skipping"
)
else:
raise
logger.info("Successfully purged Firestore collections")
except Exception as e:
logger.error(
f"Error purging Firestore collections: {str(e)}", exc_info=True
)
raise
async def _delete_collection(self, coll_ref, batch_size: int) -> None:
"""Delete a Firestore collection in batches."""
docs = []
async for doc in coll_ref.limit(batch_size).stream():
docs.append(doc)
if not docs:
return
# Delete documents in batch
batch = self.db.batch()
for doc in docs:
batch.delete(doc.reference)
await batch.commit()
# Recursively delete remaining documents
if len(docs) == batch_size:
await self._delete_collection(coll_ref, batch_size)
async def close(self):
"""Close Firestore client."""
await self.db.close()

View File

@@ -0,0 +1,285 @@
"""
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.
Dialogflow CX client service for intent detection.
"""
import logging
from google.cloud.dialogflowcx_v3 import SessionsAsyncClient
from google.cloud.dialogflowcx_v3.types import (
DetectIntentRequest,
QueryInput,
TextInput,
EventInput,
QueryParameters,
)
from google.api_core.exceptions import (
GoogleAPIError,
InternalServerError,
ServiceUnavailable,
)
from tenacity import (
retry,
stop_after_attempt,
wait_exponential,
retry_if_exception_type,
)
from ..config import Settings
from ..models import DetectIntentRequestDTO, DetectIntentResponseDTO, QueryResultDTO
logger = logging.getLogger(__name__)
class DialogflowClientService:
"""Service for interacting with Dialogflow CX API."""
def __init__(self, settings: Settings):
"""Initialize Dialogflow client."""
self.settings = settings
self.project_id = settings.dialogflow_project_id
self.location = settings.dialogflow_location
self.agent_id = settings.dialogflow_agent_id
self.default_language = settings.dialogflow_default_language
# Initialize async client
endpoint = settings.dialogflow_endpoint
client_options = {"api_endpoint": endpoint}
self.client = SessionsAsyncClient(client_options=client_options)
logger.info(
f"Dialogflow CX SessionsClient initialized for endpoint: {endpoint}"
)
logger.info(f"Agent ID: {self.agent_id}")
def _build_session_path(self, session_id: str) -> str:
"""Build Dialogflow session path."""
return self.client.session_path(
project=self.project_id,
location=self.location,
agent=self.agent_id,
session=session_id,
)
def _map_query_input(self, query_input_dto) -> QueryInput:
"""Map QueryInputDTO to Dialogflow QueryInput."""
language_code = query_input_dto.language_code or self.default_language
if query_input_dto.text and query_input_dto.text.text:
return QueryInput(
text=TextInput(text=query_input_dto.text.text),
language_code=language_code,
)
elif query_input_dto.event and query_input_dto.event.event:
return QueryInput(
event=EventInput(event=query_input_dto.event.event),
language_code=language_code,
)
else:
raise ValueError("Either text or event input must be provided")
def _map_query_params(self, query_params_dto) -> QueryParameters | None:
"""Map QueryParamsDTO to Dialogflow QueryParameters."""
if not query_params_dto or not query_params_dto.parameters:
return None
return QueryParameters(parameters=query_params_dto.parameters)
def _extract_response_text(self, response) -> str:
"""Extract text from Dialogflow response messages."""
texts = []
for msg in response.query_result.response_messages:
if hasattr(msg, "text") and msg.text.text:
texts.extend(msg.text.text)
return " ".join(texts) if texts else ""
@retry(
stop=stop_after_attempt(3),
wait=wait_exponential(multiplier=1, min=1, max=10),
retry=retry_if_exception_type((InternalServerError, ServiceUnavailable)),
reraise=True,
)
async def detect_intent(
self, session_id: str, request_dto: DetectIntentRequestDTO
) -> DetectIntentResponseDTO:
"""
Detect intent from user input using Dialogflow CX.
Args:
session_id: Unique session identifier
request_dto: Detect intent request
Returns:
Detect intent response with query results
Raises:
GoogleAPIError: If Dialogflow API call fails
"""
if not session_id:
raise ValueError("Session ID cannot be empty")
if not request_dto:
raise ValueError("Request DTO cannot be None")
logger.info(f"Initiating detectIntent for session: {session_id}")
try:
# Build request
session_path = self._build_session_path(session_id)
query_input = self._map_query_input(request_dto.query_input)
query_params = self._map_query_params(request_dto.query_params)
detect_request = DetectIntentRequest(
session=session_path,
query_input=query_input,
query_params=query_params,
)
# Call Dialogflow
logger.debug(
f"Calling Dialogflow CX detectIntent for session: {session_id}"
)
response = await self.client.detect_intent(request=detect_request)
# Extract response data
query_result = response.query_result
response_text = self._extract_response_text(response)
# Map to DTO
query_result_dto = QueryResultDTO(
responseText=response_text,
parameters=dict(query_result.parameters)
if query_result.parameters
else None,
)
result = DetectIntentResponseDTO(
responseId=response.response_id,
queryResult=query_result_dto,
)
logger.info(
f"Successfully processed detectIntent for session: {session_id}"
)
return result
except GoogleAPIError as e:
logger.error(
f"Dialogflow CX API error for session {session_id}: {e.message}",
exc_info=True,
)
raise
except Exception as e:
logger.error(
f"Unexpected error in detectIntent for session {session_id}: {str(e)}",
exc_info=True,
)
raise
@retry(
stop=stop_after_attempt(3),
wait=wait_exponential(multiplier=1, min=1, max=10),
retry=retry_if_exception_type((InternalServerError, ServiceUnavailable)),
reraise=True,
)
async def detect_intent_event(
self,
session_id: str,
event_name: str,
parameters: dict | None = None,
language_code: str | None = None,
) -> DetectIntentResponseDTO:
"""
Trigger Dialogflow event detection.
Used for notification events and system-triggered flows.
Args:
session_id: Unique session identifier
event_name: Dialogflow event name (e.g., "notificacion")
parameters: Event parameters
language_code: Language code (defaults to settings)
Returns:
Detect intent response
Raises:
GoogleAPIError: If Dialogflow API call fails
"""
if not session_id:
raise ValueError("Session ID cannot be empty")
if not event_name:
raise ValueError("Event name cannot be empty")
lang_code = language_code or self.default_language
logger.info(
f"Triggering Dialogflow event '{event_name}' for session: {session_id}"
)
try:
# Build request
session_path = self._build_session_path(session_id)
query_input = QueryInput(
event=EventInput(event=event_name),
language_code=lang_code,
)
query_params = None
if parameters:
query_params = QueryParameters(parameters=parameters)
detect_request = DetectIntentRequest(
session=session_path,
query_input=query_input,
query_params=query_params,
)
# Call Dialogflow
logger.debug(
f"Calling Dialogflow CX for event '{event_name}' in session: {session_id}"
)
response = await self.client.detect_intent(request=detect_request)
# Extract response data
query_result = response.query_result
response_text = self._extract_response_text(response)
# Map to DTO
query_result_dto = QueryResultDTO(
responseText=response_text,
parameters=dict(query_result.parameters)
if query_result.parameters
else None,
)
result = DetectIntentResponseDTO(
responseId=response.response_id,
queryResult=query_result_dto,
)
logger.info(
f"Successfully processed event '{event_name}' for session: {session_id}"
)
return result
except GoogleAPIError as e:
logger.error(
f"Dialogflow CX API error for event '{event_name}' in session {session_id}: {e.message}",
exc_info=True,
)
raise
except Exception as e:
logger.error(
f"Unexpected error triggering event '{event_name}' for session {session_id}: {str(e)}",
exc_info=True,
)
raise
async def close(self):
"""Close the Dialogflow client."""
await self.client.transport.close()
logger.info("Dialogflow CX SessionsClient closed")

View File

@@ -0,0 +1,199 @@
"""
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.
Data Loss Prevention service for obfuscating sensitive information.
"""
import logging
import re
from google.cloud import dlp_v2
from google.cloud.dlp_v2 import types
from ..config import Settings
logger = logging.getLogger(__name__)
class DLPService:
"""
Service for detecting and obfuscating sensitive data using Google Cloud DLP.
Integrates with the DLP API to scan text for PII and other sensitive information,
then obfuscates findings based on their info type.
"""
def __init__(self, settings: Settings):
"""
Initialize DLP service.
Args:
settings: Application settings
"""
self.settings = settings
self.project_id = settings.gcp_project_id
self.location = settings.gcp_location
self.dlp_client = dlp_v2.DlpServiceAsyncClient()
logger.info("DLP Service initialized")
async def get_obfuscated_string(self, text: str, template_id: str) -> str:
"""
Inspect text for sensitive data and obfuscate findings.
Args:
text: Text to inspect and obfuscate
template_id: DLP inspect template ID
Returns:
Obfuscated text with sensitive data replaced
Raises:
Exception: If DLP API call fails (returns original text on error)
"""
if not text or not text.strip():
return text
try:
# Build content item
byte_content_item = types.ByteContentItem(
type_=types.ByteContentItem.BytesType.TEXT_UTF8,
data=text.encode("utf-8"),
)
content_item = types.ContentItem(byte_item=byte_content_item)
# Build inspect config
finding_limits = types.InspectConfig.FindingLimits(
max_findings_per_item=0 # No limit
)
inspect_config = types.InspectConfig(
min_likelihood=types.Likelihood.VERY_UNLIKELY,
limits=finding_limits,
include_quote=True,
)
# Build request
inspect_template_name = f"projects/{self.project_id}/locations/{self.location}/inspectTemplates/{template_id}"
parent = f"projects/{self.project_id}/locations/{self.location}"
request = types.InspectContentRequest(
parent=parent,
inspect_template_name=inspect_template_name,
inspect_config=inspect_config,
item=content_item,
)
# Call DLP API
response = await self.dlp_client.inspect_content(request=request)
findings_count = len(response.result.findings)
logger.info(f"DLP {template_id} Findings: {findings_count}")
if findings_count > 0:
return self._obfuscate_text(response, text)
else:
return text
except Exception as e:
logger.error(
f"Error during DLP inspection: {e}. Returning original text.",
exc_info=True,
)
return text
def _obfuscate_text(self, response: types.InspectContentResponse, text: str) -> str:
"""
Obfuscate sensitive findings in text.
Args:
response: DLP inspect content response with findings
text: Original text
Returns:
Text with sensitive data obfuscated
"""
# Filter findings by likelihood (> POSSIBLE, which is value 3)
findings = [
finding
for finding in response.result.findings
if finding.likelihood.value > 3
]
# Sort by likelihood (descending)
findings.sort(key=lambda f: f.likelihood.value, reverse=True)
for finding in findings:
quote = finding.quote
info_type = finding.info_type.name
logger.info(
f"InfoType: {info_type} | Likelihood: {finding.likelihood.value}"
)
# Obfuscate based on info type
replacement = self._get_replacement(info_type, quote)
if replacement:
text = text.replace(quote, replacement)
# Clean up consecutive DIRECCION tags
text = self._clean_direccion(text)
return text
def _get_replacement(self, info_type: str, quote: str) -> str | None:
"""
Get replacement text for a given info type.
Args:
info_type: DLP info type name
quote: Original sensitive text
Returns:
Replacement text or None to skip
"""
replacements = {
"CREDIT_CARD_NUMBER": f"**** **** **** {self._get_last4(quote)}",
"CREDIT_CARD_EXPIRATION_DATE": "[FECHA_VENCIMIENTO_TARJETA]",
"FECHA_VENCIMIENTO": "[FECHA_VENCIMIENTO_TARJETA]",
"CVV_NUMBER": "[CVV]",
"CVV": "[CVV]",
"EMAIL_ADDRESS": "[CORREO]",
"PERSON_NAME": "[NOMBRE]",
"PHONE_NUMBER": "[TELEFONO]",
"DIRECCION": "[DIRECCION]",
"DIR_COLONIA": "[DIRECCION]",
"DIR_DEL_MUN": "[DIRECCION]",
"DIR_INTERIOR": "[DIRECCION]",
"DIR_ESQUINA": "[DIRECCION]",
"DIR_CIUDAD_EDO": "[DIRECCION]",
"DIR_CP": "[DIRECCION]",
"CLABE_INTERBANCARIA": "[CLABE]",
"CLAVE_RASTREO_SPEI": "[CLAVE_RASTREO]",
"NIP": "[NIP]",
"SALDO": "[SALDO]",
"CUENTA": f"**************{self._get_last4(quote)}",
"NUM_ACLARACION": "[NUM_ACLARACION]",
}
return replacements.get(info_type)
def _get_last4(self, quote: str) -> str:
"""Extract last 4 characters from quote (removing spaces)."""
clean_quote = quote.strip().replace(" ", "")
if len(clean_quote) >= 4:
return clean_quote[-4:]
return clean_quote
def _clean_direccion(self, text: str) -> str:
"""Clean up consecutive [DIRECCION] tags."""
# Replace multiple [DIRECCION] tags separated by commas or spaces with single tag
pattern = r"\[DIRECCION\](?:(?:,\s*|\s+)\[DIRECCION\])*"
return re.sub(pattern, "[DIRECCION]", text).strip()
async def close(self):
"""Close DLP client."""
await self.dlp_client.transport.close()
logger.info("DLP client closed")

View File

@@ -0,0 +1,324 @@
"""
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.
Firestore service for persistent conversation storage.
"""
import logging
from datetime import datetime
from google.cloud import firestore
from ..config import Settings
from ..models import ConversationSessionDTO, ConversationEntryDTO
from ..models.notification import NotificationDTO
logger = logging.getLogger(__name__)
class FirestoreService:
"""Service for Firestore operations on conversations."""
def __init__(self, settings: Settings):
"""Initialize Firestore client."""
self.settings = settings
self.db = firestore.AsyncClient(
project=settings.gcp_project_id,
database=settings.firestore_database_id,
)
self.conversations_collection = (
f"artifacts/{settings.gcp_project_id}/conversations"
)
self.entries_subcollection = "mensajes"
self.notifications_collection = (
f"artifacts/{settings.gcp_project_id}/notifications"
)
logger.info(
f"Firestore client initialized for project: {settings.gcp_project_id}"
)
async def close(self):
"""Close Firestore client."""
await self.db.close()
logger.info("Firestore client closed")
def _session_ref(self, session_id: str):
"""Get Firestore document reference for session."""
return self.db.collection(self.conversations_collection).document(session_id)
async def get_session(self, session_id: str) -> ConversationSessionDTO | None:
"""Retrieve conversation session from Firestore by session ID."""
try:
doc_ref = self._session_ref(session_id)
doc = await doc_ref.get()
if not doc.exists:
logger.debug(f"Session not found in Firestore: {session_id}")
return None
data = doc.to_dict()
session = ConversationSessionDTO.model_validate(data)
logger.debug(f"Retrieved session from Firestore: {session_id}")
return session
except Exception as e:
logger.error(
f"Error retrieving session {session_id} from Firestore: {str(e)}"
)
return None
async def get_session_by_phone(
self, telefono: str
) -> ConversationSessionDTO | None:
"""
Retrieve most recent conversation session from Firestore by phone number.
Args:
telefono: User phone number
Returns:
Most recent session for this phone, or None if not found
"""
try:
query = (
self.db.collection(self.sessions_collection)
.where("telefono", "==", telefono)
.order_by("lastModified", direction=firestore.Query.DESCENDING)
.limit(1)
)
docs = query.stream()
async for doc in docs:
data = doc.to_dict()
session = ConversationSessionDTO.model_validate(data)
logger.debug(
f"Retrieved session from Firestore for phone {telefono}: {session.sessionId}"
)
return session
logger.debug(f"No session found in Firestore for phone: {telefono}")
return None
except Exception as e:
logger.error(
f"Error querying session by phone {telefono} from Firestore: {str(e)}"
)
return None
async def save_session(self, session: ConversationSessionDTO) -> bool:
"""Save conversation session to Firestore."""
try:
doc_ref = self._session_ref(session.sessionId)
data = session.model_dump()
await doc_ref.set(data, merge=True)
logger.debug(f"Saved session to Firestore: {session.sessionId}")
return True
except Exception as e:
logger.error(
f"Error saving session {session.sessionId} to Firestore: {str(e)}"
)
return False
async def save_entry(self, session_id: str, entry: ConversationEntryDTO) -> bool:
"""Save conversation entry to Firestore subcollection."""
try:
doc_ref = self._session_ref(session_id)
entries_ref = doc_ref.collection(self.entries_subcollection)
# Use timestamp as document ID for chronological ordering
entry_id = entry.timestamp.isoformat()
entry_doc = entries_ref.document(entry_id)
data = entry.model_dump()
await entry_doc.set(data)
logger.debug(f"Saved entry to Firestore for session: {session_id}")
return True
except Exception as e:
logger.error(
f"Error saving entry for session {session_id} to Firestore: {str(e)}"
)
return False
async def get_entries(
self, session_id: str, limit: int = 10
) -> list[ConversationEntryDTO]:
"""Retrieve recent conversation entries from Firestore."""
try:
doc_ref = self._session_ref(session_id)
entries_ref = doc_ref.collection(self.entries_subcollection)
# Get entries ordered by timestamp descending
query = entries_ref.order_by(
"timestamp", direction=firestore.Query.DESCENDING
).limit(limit)
docs = query.stream()
entries = []
async for doc in docs:
entry_data = doc.to_dict()
entry = ConversationEntryDTO.model_validate(entry_data)
entries.append(entry)
# Reverse to get chronological order
entries.reverse()
logger.debug(f"Retrieved {len(entries)} entries for session: {session_id}")
return entries
except Exception as e:
logger.error(
f"Error retrieving entries for session {session_id} from Firestore: {str(e)}"
)
return []
async def delete_session(self, session_id: str) -> bool:
"""Delete conversation session and all entries from Firestore."""
try:
doc_ref = self._session_ref(session_id)
# Delete all entries first
entries_ref = doc_ref.collection(self.entries_subcollection)
async for doc in entries_ref.stream():
await doc.reference.delete()
# Delete session document
await doc_ref.delete()
logger.debug(f"Deleted session from Firestore: {session_id}")
return True
except Exception as e:
logger.error(
f"Error deleting session {session_id} from Firestore: {str(e)}"
)
return False
# ====== Notification Methods ======
def _notification_ref(self, notification_id: str):
"""Get Firestore document reference for notification."""
return self.db.collection(self.notifications_collection).document(
notification_id
)
async def save_or_append_notification(self, new_entry: NotificationDTO) -> None:
"""
Save or append notification entry to Firestore.
Args:
new_entry: Notification entry to save
Raises:
ValueError: If phone number is missing
"""
phone_number = new_entry.telefono
if not phone_number or not phone_number.strip():
raise ValueError("Phone number is required to manage notification entries")
# Use phone number as document ID
notification_session_id = phone_number
try:
doc_ref = self._notification_ref(notification_session_id)
doc = await doc_ref.get()
entry_dict = new_entry.model_dump()
if doc.exists:
# Append to existing session
await doc_ref.update(
{
"notificaciones": firestore.ArrayUnion([entry_dict]),
"ultimaActualizacion": datetime.now(),
}
)
logger.info(
f"Successfully appended notification entry to session {notification_session_id} in Firestore"
)
else:
# Create new notification session
new_session_data = {
"sessionId": notification_session_id,
"telefono": phone_number,
"fechaCreacion": datetime.now(),
"ultimaActualizacion": datetime.now(),
"notificaciones": [entry_dict],
}
await doc_ref.set(new_session_data)
logger.info(
f"Successfully created new notification session {notification_session_id} in Firestore"
)
except Exception as e:
logger.error(
f"Error saving notification to Firestore for phone {phone_number}: {str(e)}",
exc_info=True,
)
raise
async def update_notification_status(self, session_id: str, status: str) -> None:
"""
Update the status of all notifications in a session.
Args:
session_id: Notification session ID (phone number)
status: New status value
"""
try:
doc_ref = self._notification_ref(session_id)
doc = await doc_ref.get()
if not doc.exists:
logger.warning(
f"Notification session {session_id} not found in Firestore. Cannot update status"
)
return
session_data = doc.to_dict()
notifications = session_data.get("notificaciones", [])
# Update status for all notifications
updated_notifications = [
{**notif, "status": status} for notif in notifications
]
await doc_ref.update(
{
"notificaciones": updated_notifications,
"ultimaActualizacion": datetime.now(),
}
)
logger.info(
f"Successfully updated notification status to '{status}' for session {session_id} in Firestore"
)
except Exception as e:
logger.error(
f"Error updating notification status in Firestore for session {session_id}: {str(e)}",
exc_info=True,
)
raise
async def delete_notification(self, notification_id: str) -> bool:
"""Delete notification session from Firestore."""
try:
logger.info(
f"Deleting notification session {notification_id} from Firestore"
)
doc_ref = self._notification_ref(notification_id)
await doc_ref.delete()
logger.info(
f"Successfully deleted notification session {notification_id} from Firestore"
)
return True
except Exception as e:
logger.error(
f"Error deleting notification session {notification_id} from Firestore: {str(e)}",
exc_info=True,
)
return False

View File

@@ -0,0 +1,100 @@
"""
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.
Gemini client service for LLM operations.
"""
import logging
import google.generativeai as genai
from ..config import Settings
logger = logging.getLogger(__name__)
class GeminiClientException(Exception):
"""Exception raised for Gemini API errors."""
pass
class GeminiClientService:
"""Service for interacting with Google Gemini API."""
def __init__(self, settings: Settings):
"""Initialize Gemini client."""
self.settings = settings
# Configure the Gemini API
genai.configure()
logger.info("Gemini client initialized successfully")
async def generate_content(
self,
prompt: str,
temperature: float,
max_output_tokens: int,
model_name: str,
top_p: float,
) -> str:
"""
Generate content using Gemini API.
Args:
prompt: The prompt text to send to Gemini
temperature: Sampling temperature (0.0 to 1.0)
max_output_tokens: Maximum number of tokens to generate
model_name: Gemini model name (e.g., "gemini-2.0-flash-exp")
top_p: Top-p sampling parameter
Returns:
Generated text response from Gemini
Raises:
GeminiClientException: If API call fails
"""
try:
logger.debug(f"Sending request to Gemini model '{model_name}'")
# Create generation config
generation_config = genai.GenerationConfig(
temperature=temperature,
max_output_tokens=max_output_tokens,
top_p=top_p,
)
# Initialize model
model = genai.GenerativeModel(
model_name=model_name,
generation_config=generation_config,
)
# Generate content
response = await model.generate_content_async(prompt)
if response and response.text:
logger.debug(
f"Received response from Gemini: {len(response.text)} characters"
)
return response.text
else:
logger.warning(
f"Gemini returned no content or unexpected response structure for model '{model_name}'"
)
raise GeminiClientException(
"No content generated or unexpected response structure"
)
except Exception as e:
logger.error(
f"Error during Gemini content generation for model '{model_name}': {e}",
exc_info=True,
)
raise GeminiClientException(
f"An error occurred during content generation: {str(e)}"
) from e

View File

@@ -0,0 +1,105 @@
"""
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.
LLM Response Tuner service for storing/retrieving pre-generated responses.
"""
import logging
from redis.asyncio import Redis
logger = logging.getLogger(__name__)
class LlmResponseTunerService:
"""
Service for managing pre-generated LLM responses in Redis.
Used as a webhook bridge where:
1. LLM responses are pre-generated and stored with a UUID
2. Dialogflow webhook calls this service with the UUID
3. Service retrieves and returns the response
"""
def __init__(self, redis: Redis):
"""
Initialize LLM response tuner service.
Args:
redis: Redis client instance
"""
self.redis = redis
self.collection_prefix = "llm-pre-response:"
self.ttl = 3600 # 1 hour in seconds
logger.info("LlmResponseTunerService initialized")
def _get_key(self, uuid: str) -> str:
"""Generate Redis key for UUID."""
return f"{self.collection_prefix}{uuid}"
async def get_value(self, uuid: str) -> str | None:
"""
Retrieve pre-generated response by UUID.
Args:
uuid: Unique identifier for the response
Returns:
Response text or None if not found
"""
if not uuid or not uuid.strip():
logger.warning("UUID is null or blank")
return None
key = self._get_key(uuid)
try:
value = await self.redis.get(key)
if value:
logger.info(f"Retrieved LLM response for UUID: {uuid}")
return value
else:
logger.warning(f"No response found for UUID: {uuid}")
return None
except Exception as e:
logger.error(
f"Error retrieving LLM response for UUID {uuid}: {e}", exc_info=True
)
return None
async def set_value(self, uuid: str, value: str) -> bool:
"""
Store pre-generated response with UUID.
Args:
uuid: Unique identifier for the response
value: Response text to store
Returns:
True if successful, False otherwise
"""
if not uuid or not uuid.strip():
logger.warning("UUID is null or blank")
return False
if not value:
logger.warning("Value is null or empty")
return False
key = self._get_key(uuid)
try:
await self.redis.setex(key, self.ttl, value)
logger.info(f"Stored LLM response for UUID: {uuid} with TTL: {self.ttl}s")
return True
except Exception as e:
logger.error(
f"Error storing LLM response for UUID {uuid}: {e}", exc_info=True
)
return False

View File

@@ -0,0 +1,229 @@
"""
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.
Mappers for converting DTOs to text format for Gemini API.
"""
import json
import logging
from datetime import datetime, timedelta
from ..models import (
ConversationSessionDTO,
ConversationEntryDTO,
)
from ..models.notification import NotificationDTO
logger = logging.getLogger(__name__)
class NotificationContextMapper:
"""Maps notifications to text format for Gemini classification."""
@staticmethod
def to_text(notification: NotificationDTO) -> str:
"""
Convert a notification to text format.
Args:
notification: Notification DTO
Returns:
Notification text
"""
if not notification or not notification.texto:
return ""
return notification.texto
@staticmethod
def to_text_multiple(notifications: list[NotificationDTO]) -> str:
"""
Convert multiple notifications to text format.
Args:
notifications: List of notification DTOs
Returns:
Notifications joined by newlines
"""
if not notifications:
return ""
texts = [n.texto for n in notifications if n.texto and n.texto.strip()]
return "\n".join(texts)
@staticmethod
def to_json(notification: NotificationDTO) -> str:
"""
Convert notification to JSON string for Gemini.
Args:
notification: Notification DTO
Returns:
JSON representation
"""
if not notification:
return "{}"
data = {
"texto": notification.texto,
"parametros": notification.parametros or {},
"timestamp": notification.timestampCreacion.isoformat(),
}
return json.dumps(data, ensure_ascii=False)
class ConversationContextMapper:
"""Maps conversation history to text format for Gemini."""
# Business rules for conversation history limits
MESSAGE_LIMIT = 60 # Maximum 60 messages
DAYS_LIMIT = 30 # Maximum 30 days
MAX_HISTORY_BYTES = 50 * 1024 # 50 KB maximum size
NOTIFICATION_TEXT_PARAM = "notification_text"
def __init__(self, message_limit: int = 60, days_limit: int = 30):
"""
Initialize conversation context mapper.
Args:
message_limit: Maximum number of messages to include
days_limit: Maximum age of messages in days
"""
self.message_limit = message_limit
self.days_limit = days_limit
def to_text_from_entries(self, entries: list[ConversationEntryDTO]) -> str:
"""
Convert conversation entries to text format.
Args:
entries: List of conversation entries
Returns:
Formatted conversation history
"""
if not entries:
return ""
formatted = [self._format_entry(entry) for entry in entries]
return "\n".join(formatted)
def to_text_with_limits(
self,
session: ConversationSessionDTO,
entries: list[ConversationEntryDTO],
) -> str:
"""
Convert conversation to text with business rule limits applied.
Applies:
- Days limit (30 days)
- Message limit (60 messages)
- Size limit (50 KB)
Args:
session: Conversation session
entries: List of conversation entries
Returns:
Formatted conversation history with limits applied
"""
if not entries:
return ""
# Filter by date (30 days)
cutoff_date = datetime.now() - timedelta(days=self.days_limit)
recent_entries = [
e for e in entries if e.timestamp and e.timestamp >= cutoff_date
]
# Sort by timestamp (oldest first) and limit count
recent_entries.sort(key=lambda e: e.timestamp)
limited_entries = recent_entries[-self.message_limit :]
# Apply size truncation (50 KB)
return self._to_text_with_truncation(limited_entries)
def _to_text_with_truncation(self, entries: list[ConversationEntryDTO]) -> str:
"""
Format entries with size truncation (50 KB max).
Args:
entries: List of conversation entries
Returns:
Formatted text, truncated if necessary
"""
if not entries:
return ""
# Format all messages
formatted_messages = [self._format_entry(entry) for entry in entries]
# Build from newest to oldest, stopping at 50KB
text_block = []
current_size = 0
# Iterate from newest to oldest
for message in reversed(formatted_messages):
message_line = message + "\n"
message_bytes = len(message_line.encode("utf-8"))
if current_size + message_bytes > self.MAX_HISTORY_BYTES:
break
text_block.insert(0, message_line)
current_size += message_bytes
return "".join(text_block).strip()
def _format_entry(self, entry: ConversationEntryDTO) -> str:
"""
Format a single conversation entry.
Args:
entry: Conversation entry
Returns:
Formatted string (e.g., "User: hello", "Agent: hi there")
"""
prefix = "User: "
content = entry.text
# Determine prefix based on entity
if entry.entity == "AGENTE":
prefix = "Agent: "
# Clean JSON artifacts from agent messages
content = self._clean_agent_message(content)
elif entry.entity == "SISTEMA":
prefix = "System: "
# Check if this is a notification in parameters
if entry.parameters and self.NOTIFICATION_TEXT_PARAM in entry.parameters:
param_text = entry.parameters[self.NOTIFICATION_TEXT_PARAM]
if param_text and str(param_text).strip():
content = str(param_text)
elif entry.entity == "LLM":
prefix = "System: "
return prefix + content
def _clean_agent_message(self, message: str) -> str:
"""
Clean agent message by removing JSON artifacts at the end.
Args:
message: Original message
Returns:
Cleaned message
"""
# Remove trailing {...} patterns
import re
return re.sub(r"\s*\{.*\}\s*$", "", message).strip()

View File

@@ -0,0 +1,156 @@
"""
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.
Message classification service using Gemini LLM.
"""
import logging
from ..config import Settings
from .gemini_client import GeminiClientService, GeminiClientException
logger = logging.getLogger(__name__)
class MessageEntryFilter:
"""
Classifies a user's text input into a predefined category using Gemini.
Analyzes user queries in the context of conversation history and
notifications to determine if the message is part of ongoing dialogue
or an interruption. Classification is used to route requests to the
appropriate handler.
"""
# Classification categories
CATEGORY_CONVERSATION = "CONVERSATION"
CATEGORY_NOTIFICATION = "NOTIFICATION"
CATEGORY_UNKNOWN = "UNKNOWN"
CATEGORY_ERROR = "ERROR"
def __init__(self, settings: Settings, gemini_service: GeminiClientService):
"""
Initialize message filter.
Args:
settings: Application settings
gemini_service: Gemini client service
"""
self.settings = settings
self.gemini_service = gemini_service
self.prompt_template = self._load_prompt_template()
logger.info("MessageEntryFilter initialized successfully")
def _load_prompt_template(self) -> str:
"""Load the prompt template from resources."""
prompt_path = self.settings.base_path / self.settings.message_filter_prompt_path
try:
with open(prompt_path, "r", encoding="utf-8") as f:
prompt_template = f.read()
logger.info(f"Successfully loaded prompt template from '{prompt_path}'")
return prompt_template
except Exception as e:
logger.error(
f"Failed to load prompt template from '{prompt_path}': {e}",
exc_info=True,
)
raise RuntimeError("Could not load prompt template") from e
async def classify_message(
self,
query_input_text: str,
notifications_json: str | None = None,
conversation_json: str | None = None,
) -> str:
"""
Classify a user message as CONVERSATION, NOTIFICATION, or UNKNOWN.
Args:
query_input_text: The user's input text to classify
notifications_json: JSON string of interrupting notifications (optional)
conversation_json: JSON string of conversation history (optional)
Returns:
Classification category (CONVERSATION, NOTIFICATION, or UNKNOWN)
"""
if not query_input_text or not query_input_text.strip():
logger.warning(
f"Query input text for classification is null or blank. Returning {self.CATEGORY_UNKNOWN}"
)
return self.CATEGORY_UNKNOWN
# Prepare context strings
interrupting_notification = (
notifications_json
if notifications_json and notifications_json.strip()
else "No interrupting notification."
)
conversation_history = (
conversation_json
if conversation_json and conversation_json.strip()
else "No conversation history."
)
# Format the classification prompt
classification_prompt = self.prompt_template % (
conversation_history,
interrupting_notification,
query_input_text,
)
logger.debug(
f"Sending classification request to Gemini for input (first 100 chars): "
f"'{query_input_text[:100]}...'"
)
try:
# Call Gemini API
gemini_response = await self.gemini_service.generate_content(
prompt=classification_prompt,
temperature=self.settings.message_filter_temperature,
max_output_tokens=self.settings.message_filter_max_tokens,
model_name=self.settings.message_filter_model,
top_p=self.settings.message_filter_top_p,
)
# Parse and validate response
if not gemini_response:
logger.warning(
f"Gemini returned null/blank response. Returning {self.CATEGORY_UNKNOWN}"
)
return self.CATEGORY_UNKNOWN
response_upper = gemini_response.strip().upper()
if response_upper == self.CATEGORY_CONVERSATION:
logger.info(f"Classified as {self.CATEGORY_CONVERSATION}")
return self.CATEGORY_CONVERSATION
elif response_upper == self.CATEGORY_NOTIFICATION:
logger.info(f"Classified as {self.CATEGORY_NOTIFICATION}")
return self.CATEGORY_NOTIFICATION
else:
logger.warning(
f"Gemini returned unrecognized classification: '{gemini_response}'. "
f"Expected '{self.CATEGORY_CONVERSATION}' or '{self.CATEGORY_NOTIFICATION}'. "
f"Returning {self.CATEGORY_UNKNOWN}"
)
return self.CATEGORY_UNKNOWN
except GeminiClientException as e:
logger.error(
f"Error during Gemini content generation for message classification: {e}",
exc_info=True,
)
return self.CATEGORY_UNKNOWN
except Exception as e:
logger.error(
f"Unexpected error during message classification: {e}",
exc_info=True,
)
return self.CATEGORY_UNKNOWN

View File

@@ -0,0 +1,192 @@
"""
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.
Notification context resolver using Gemini LLM.
"""
import logging
from ..config import Settings
from .gemini_client import GeminiClientService, GeminiClientException
logger = logging.getLogger(__name__)
class NotificationContextResolver:
"""
Resolves conversational context using LLM to answer notification-related questions.
Evaluates a user's question in the context of a notification and conversation
history. Decides if the query can be answered by the LLM (using notification
metadata) or should be delegated to Dialogflow.
"""
# Response categories
CATEGORY_DIALOGFLOW = "DIALOGFLOW"
def __init__(self, settings: Settings, gemini_service: GeminiClientService):
"""
Initialize notification context resolver.
Args:
settings: Application settings
gemini_service: Gemini client service
"""
self.settings = settings
self.gemini_service = gemini_service
# Load settings (with defaults matching Java)
self.model_name = getattr(
settings, "notification_context_model", "gemini-2.0-flash-001"
)
self.temperature = getattr(settings, "notification_context_temperature", 0.1)
self.max_tokens = getattr(settings, "notification_context_max_tokens", 1024)
self.top_p = getattr(settings, "notification_context_top_p", 0.1)
self.prompt_path = getattr(
settings,
"notification_context_prompt_path",
"prompts/notification_context_resolver.txt",
)
self.prompt_template = self._load_prompt_template()
logger.info("NotificationContextResolver initialized successfully")
def _load_prompt_template(self) -> str:
"""Load the prompt template from resources."""
prompt_path = self.settings.base_path / self.prompt_path
try:
with open(prompt_path, "r", encoding="utf-8") as f:
prompt_template = f.read()
logger.info(f"Successfully loaded prompt template from '{prompt_path}'")
return prompt_template
except Exception as e:
logger.error(
f"Failed to load prompt template from '{prompt_path}': {e}",
exc_info=True,
)
raise RuntimeError("Could not load prompt template") from e
async def resolve_context(
self,
query_input_text: str,
notifications_json: str | None = None,
conversation_json: str | None = None,
metadata: str | None = None,
user_id: str | None = None,
session_id: str | None = None,
user_phone_number: str | None = None,
) -> str:
"""
Resolve context and generate response for notification-related question.
Uses Gemini to analyze the question against notification metadata and
conversation history. Returns either:
- A direct answer generated by the LLM
- "DIALOGFLOW" to delegate to standard Dialogflow flow
Priority order for finding answers:
1. METADATOS_NOTIFICACION (highest authority)
2. HISTORIAL_CONVERSACION parameters with "notification_po_" prefix
3. If not found, return "DIALOGFLOW"
Args:
query_input_text: User's question
notifications_json: JSON string of notifications
conversation_json: JSON string of conversation history
metadata: Structured notification metadata
user_id: User identifier (optional, for logging)
session_id: Session identifier (optional, for logging)
user_phone_number: User phone number (optional, for logging)
Returns:
Either a direct LLM-generated answer or "DIALOGFLOW"
"""
logger.debug(
f"resolveContext -> queryInputText: {query_input_text}, "
f"notificationsJson: {notifications_json}, "
f"conversationJson: {conversation_json}, "
f"metadata: {metadata}"
)
if not query_input_text or not query_input_text.strip():
logger.warning(
f"Query input text for context resolution is null or blank. "
f"Returning {self.CATEGORY_DIALOGFLOW}"
)
return self.CATEGORY_DIALOGFLOW
# Prepare context strings
notification_content = (
notifications_json
if notifications_json and notifications_json.strip()
else "No metadata in notification."
)
conversation_history = (
conversation_json
if conversation_json and conversation_json.strip()
else "No conversation history."
)
notification_metadata = metadata if metadata and metadata.strip() else "{}"
# Format the context resolution prompt
context_prompt = self.prompt_template % (
conversation_history,
notification_content,
notification_metadata,
query_input_text,
)
logger.debug(
f"Sending context resolution request to Gemini for input (first 100 chars): "
f"'{query_input_text[:100]}...'"
)
try:
# Call Gemini API
gemini_response = await self.gemini_service.generate_content(
prompt=context_prompt,
temperature=self.temperature,
max_output_tokens=self.max_tokens,
model_name=self.model_name,
top_p=self.top_p,
)
if gemini_response and gemini_response.strip():
# Check if response is delegation to Dialogflow
if gemini_response.strip().upper() == self.CATEGORY_DIALOGFLOW:
logger.debug(
f"Resolved to {self.CATEGORY_DIALOGFLOW}. Input: '{query_input_text}'"
)
return self.CATEGORY_DIALOGFLOW
else:
# LLM provided a direct answer
logger.debug(
f"Resolved to a specific response. Input: '{query_input_text}'"
)
return gemini_response
else:
logger.warning(
f"Gemini returned a null or blank response. "
f"Returning {self.CATEGORY_DIALOGFLOW}"
)
return self.CATEGORY_DIALOGFLOW
except GeminiClientException as e:
logger.error(
f"Error during Gemini content generation for context resolution: {e}",
exc_info=True,
)
return self.CATEGORY_DIALOGFLOW
except Exception as e:
logger.error(
f"Unexpected error during context resolution: {e}",
exc_info=True,
)
return self.CATEGORY_DIALOGFLOW

View File

@@ -0,0 +1,259 @@
"""
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.
Notification manager service for processing push notifications.
"""
import logging
from datetime import datetime
from ..config import Settings
from ..models import DetectIntentResponseDTO
from ..models.notification import ExternalNotRequestDTO, NotificationDTO
from ..models.conversation import ConversationSessionDTO, ConversationEntryDTO
from ..utils.session_id import generate_session_id
from .dialogflow_client import DialogflowClientService
from .redis_service import RedisService
from .firestore_service import FirestoreService
from .dlp_service import DLPService
logger = logging.getLogger(__name__)
PREFIX_PO_PARAM = "notification_po_"
class NotificationManagerService:
"""
Manages notification processing and integration with conversations.
Handles push notifications from external systems, stores them in
Redis/Firestore, and triggers Dialogflow event detection.
"""
def __init__(
self,
settings: Settings,
dialogflow_client: DialogflowClientService,
redis_service: RedisService,
firestore_service: FirestoreService,
dlp_service: DLPService,
):
"""
Initialize notification manager.
Args:
settings: Application settings
dialogflow_client: Dialogflow CX client
redis_service: Redis caching service
firestore_service: Firestore persistence service
dlp_service: Data Loss Prevention service
"""
self.settings = settings
self.dialogflow_client = dialogflow_client
self.redis_service = redis_service
self.firestore_service = firestore_service
self.dlp_service = dlp_service
self.default_language_code = settings.dialogflow_default_language
self.event_name = "notificacion"
logger.info("NotificationManagerService initialized")
async def process_notification(
self, external_request: ExternalNotRequestDTO
) -> DetectIntentResponseDTO:
"""
Process a push notification from external system.
Flow:
1. Validate phone number
2. Obfuscate sensitive data (DLP - TODO)
3. Create notification entry
4. Save to Redis and Firestore
5. Get or create conversation session
6. Add notification to conversation history
7. Trigger Dialogflow event
Args:
external_request: External notification request
Returns:
Dialogflow detect intent response
Raises:
ValueError: If phone number is missing
"""
telefono = external_request.telefono
if not telefono or not telefono.strip():
logger.warning("No phone number provided in notification request")
raise ValueError("Phone number is required")
# Obfuscate sensitive data using DLP
obfuscated_text = await self.dlp_service.get_obfuscated_string(
external_request.texto,
self.settings.dlp_template_complete_flow,
)
# Prepare parameters with prefix
parameters = {}
if external_request.parametros_ocultos:
for key, value in external_request.parametros_ocultos.items():
parameters[f"{PREFIX_PO_PARAM}{key}"] = value
# Create notification entry
new_notification_id = generate_session_id()
new_notification_entry = NotificationDTO(
idNotificacion=new_notification_id,
telefono=telefono,
timestampCreacion=datetime.now(),
texto=obfuscated_text,
nombreEventoDialogflow=self.event_name,
codigoIdiomaDialogflow=self.default_language_code,
parametros=parameters,
status="active",
)
# Save notification to Redis (with async Firestore write-back)
await self.redis_service.save_or_append_notification(new_notification_entry)
logger.info(
f"Notification for phone {telefono} cached. Kicking off async Firestore write-back"
)
# Fire-and-forget Firestore write
# In production, consider using asyncio.create_task() with proper error handling
try:
await self.firestore_service.save_or_append_notification(
new_notification_entry
)
logger.debug(
f"Notification entry persisted to Firestore for phone {telefono}"
)
except Exception as e:
logger.error(
f"Background: Error during notification persistence to Firestore for phone {telefono}: {e}",
exc_info=True,
)
# Get or create conversation session
session = await self._get_or_create_conversation_session(
telefono, obfuscated_text, parameters
)
# Send notification event to Dialogflow
logger.info(
f"Sending notification text to Dialogflow using conversation session: {session.sessionId}"
)
response = await self.dialogflow_client.detect_intent_event(
session_id=session.sessionId,
event_name=self.event_name,
parameters=parameters,
language_code=self.default_language_code,
)
logger.info(
f"Finished processing notification. Dialogflow response received for phone {telefono}"
)
return response
async def _get_or_create_conversation_session(
self, telefono: str, notification_text: str, parameters: dict
) -> ConversationSessionDTO:
"""
Get existing conversation session or create a new one.
Also persists system entry for the notification.
Args:
telefono: User phone number
notification_text: Notification text content
parameters: Notification parameters
Returns:
Conversation session
"""
# Try to get existing session by phone
# TODO: Need to implement get_session_by_telefono in Redis service
# For now, we'll create a new session
new_session_id = generate_session_id()
user_id = f"user_by_phone_{telefono}"
logger.info(
f"Creating new conversation session {new_session_id} for notification (phone: {telefono})"
)
# Create system entry for notification
system_entry = ConversationEntryDTO(
entity="SISTEMA",
type="SISTEMA",
timestamp=datetime.now(),
text=notification_text,
parameters=parameters,
intent=None,
)
# Create new session
new_session = ConversationSessionDTO(
sessionId=new_session_id,
userId=user_id,
telefono=telefono,
createdAt=datetime.now(),
lastModified=datetime.now(),
lastMessage=notification_text,
pantallaContexto=None,
)
# Persist conversation turn (session + system entry)
await self._persist_conversation_turn(new_session, system_entry)
return new_session
async def _persist_conversation_turn(
self, session: ConversationSessionDTO, entry: ConversationEntryDTO
) -> None:
"""
Persist conversation turn to Redis and Firestore.
Uses write-through caching: writes to Redis first, then async to Firestore.
Args:
session: Conversation session
entry: Conversation entry to persist
"""
logger.debug(
f"Starting Write-Back persistence for notification session {session.sessionId}. "
f"Type: {entry.type}. Writing to Redis first"
)
# Update session with last message
updated_session = ConversationSessionDTO(
**session.model_dump(),
lastMessage=entry.text,
lastModified=datetime.now(),
)
# Save to Redis
await self.redis_service.save_session(updated_session)
logger.info(
f"Entry saved to Redis for notification session {session.sessionId}. "
f"Type: {entry.type}. Kicking off async Firestore write-back"
)
# Fire-and-forget Firestore writes
try:
await self.firestore_service.save_session(updated_session)
await self.firestore_service.save_entry(session.sessionId, entry)
logger.debug(
f"Asynchronously (Write-Back): Entry successfully saved to Firestore "
f"for notification session {session.sessionId}. Type: {entry.type}"
)
except Exception as e:
logger.error(
f"Asynchronously (Write-Back): Failed to save entry to Firestore "
f"for notification session {session.sessionId}. Type: {entry.type}: {e}",
exc_info=True,
)

View File

@@ -0,0 +1,98 @@
"""
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.
Quick Reply content service for loading FAQ screens.
"""
import json
import logging
from ..config import Settings
from ..models.quick_replies import QuickReplyDTO, QuestionDTO
logger = logging.getLogger(__name__)
class QuickReplyContentService:
"""Service for loading quick reply screen content from JSON files."""
def __init__(self, settings: Settings):
"""
Initialize quick reply content service.
Args:
settings: Application settings
"""
self.settings = settings
self.quick_replies_path = settings.base_path / "quick_replies"
logger.info(
f"QuickReplyContentService initialized with path: {self.quick_replies_path}"
)
async def get_quick_replies(self, screen_id: str) -> QuickReplyDTO | None:
"""
Load quick reply screen content by ID.
Args:
screen_id: Screen identifier (e.g., "pagos", "home")
Returns:
Quick reply DTO or None if not found
"""
if not screen_id or not screen_id.strip():
logger.warning("screen_id is null or empty. Returning empty quick replies")
return QuickReplyDTO(
header="empty",
body=None,
button=None,
header_section=None,
preguntas=[],
)
file_path = self.quick_replies_path / f"{screen_id}.json"
try:
if not file_path.exists():
logger.warning(f"Quick reply file not found: {file_path}")
return None
with open(file_path, "r", encoding="utf-8") as f:
data = json.load(f)
# Parse questions
preguntas_data = data.get("preguntas", [])
preguntas = [
QuestionDTO(
titulo=q.get("titulo", ""),
descripcion=q.get("descripcion"),
respuesta=q.get("respuesta", ""),
)
for q in preguntas_data
]
quick_reply = QuickReplyDTO(
header=data.get("header"),
body=data.get("body"),
button=data.get("button"),
header_section=data.get("header_section"),
preguntas=preguntas,
)
logger.info(
f"Successfully loaded {len(preguntas)} quick replies for screen: {screen_id}"
)
return quick_reply
except json.JSONDecodeError as e:
logger.error(f"Error parsing JSON file {file_path}: {e}", exc_info=True)
return None
except Exception as e:
logger.error(
f"Error loading quick replies for screen {screen_id}: {e}",
exc_info=True,
)
return None

View File

@@ -0,0 +1,373 @@
"""
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.
Redis service for caching conversation sessions.
"""
import json
import logging
from datetime import datetime
from redis.asyncio import Redis
from ..config import Settings
from ..models import ConversationSessionDTO
from ..models.notification import NotificationSessionDTO, NotificationDTO
logger = logging.getLogger(__name__)
class RedisService:
"""Service for Redis operations on conversation sessions."""
def __init__(self, settings: Settings):
"""Initialize Redis client."""
self.settings = settings
self.redis: Redis | None = None
self.session_ttl = 2592000 # 30 days in seconds
self.notification_ttl = 2592000 # 30 days in seconds
async def connect(self):
"""Connect to Redis."""
self.redis = Redis(
host=self.settings.redis_host,
port=self.settings.redis_port,
password=self.settings.redis_password,
ssl=self.settings.redis_ssl,
decode_responses=True,
)
logger.info(
f"Connected to Redis at {self.settings.redis_host}:{self.settings.redis_port}"
)
async def close(self):
"""Close Redis connection."""
if self.redis:
await self.redis.close()
logger.info("Redis connection closed")
def _session_key(self, session_id: str) -> str:
"""Generate Redis key for conversation session."""
return f"conversation:session:{session_id}"
def _phone_to_session_key(self, phone: str) -> str:
"""Generate Redis key for phone-to-session mapping."""
return f"conversation:phone:{phone}"
async def get_session(
self, session_id_or_phone: str
) -> ConversationSessionDTO | None:
"""
Retrieve conversation session from Redis by session ID or phone number.
Args:
session_id_or_phone: Either a session ID or phone number
Returns:
Conversation session or None if not found
"""
if not self.redis:
raise RuntimeError("Redis client not connected")
# First try as phone number (lookup session ID)
phone_key = self._phone_to_session_key(session_id_or_phone)
mapped_session_id = await self.redis.get(phone_key)
if mapped_session_id:
# Found phone mapping, get the actual session
session_id = mapped_session_id
else:
# Try as direct session ID
session_id = session_id_or_phone
# Get session by ID
key = self._session_key(session_id)
data = await self.redis.get(key)
if not data:
logger.debug(f"Session not found in Redis: {session_id_or_phone}")
return None
try:
session_dict = json.loads(data)
session = ConversationSessionDTO.model_validate(session_dict)
logger.debug(f"Retrieved session from Redis: {session_id}")
return session
except Exception as e:
logger.error(f"Error deserializing session {session_id}: {str(e)}")
return None
async def save_session(self, session: ConversationSessionDTO) -> bool:
"""
Save conversation session to Redis with TTL.
Also stores phone-to-session mapping for lookup by phone number.
"""
if not self.redis:
raise RuntimeError("Redis client not connected")
key = self._session_key(session.sessionId)
phone_key = self._phone_to_session_key(session.telefono)
try:
# Save session data
data = session.model_dump_json(by_alias=False)
await self.redis.setex(key, self.session_ttl, data)
# Save phone-to-session mapping
await self.redis.setex(phone_key, self.session_ttl, session.sessionId)
logger.debug(
f"Saved session to Redis: {session.sessionId} for phone: {session.telefono}"
)
return True
except Exception as e:
logger.error(f"Error saving session {session.sessionId} to Redis: {str(e)}")
return False
async def delete_session(self, session_id: str) -> bool:
"""Delete conversation session from Redis."""
if not self.redis:
raise RuntimeError("Redis client not connected")
key = self._session_key(session_id)
try:
result = await self.redis.delete(key)
logger.debug(f"Deleted session from Redis: {session_id}")
return result > 0
except Exception as e:
logger.error(f"Error deleting session {session_id} from Redis: {str(e)}")
return False
async def exists(self, session_id: str) -> bool:
"""Check if session exists in Redis."""
if not self.redis:
raise RuntimeError("Redis client not connected")
key = self._session_key(session_id)
return await self.redis.exists(key) > 0
# ====== Message Methods ======
def _messages_key(self, session_id: str) -> str:
"""Generate Redis key for conversation messages."""
return f"conversation:messages:{session_id}"
async def save_message(self, session_id: str, message) -> bool:
"""
Save a conversation message to Redis sorted set.
Messages are stored in a sorted set with timestamp as score.
Args:
session_id: The session ID
message: ConversationMessageDTO or ConversationEntryDTO
Returns:
True if successful, False otherwise
"""
if not self.redis:
raise RuntimeError("Redis client not connected")
key = self._messages_key(session_id)
try:
# Convert message to JSON
message_data = message.model_dump_json(by_alias=False)
# Use timestamp as score (in milliseconds)
score = message.timestamp.timestamp() * 1000
# Add to sorted set
await self.redis.zadd(key, {message_data: score})
# Set TTL on the messages key to match session TTL
await self.redis.expire(key, self.session_ttl)
logger.debug(f"Saved message to Redis: {session_id}")
return True
except Exception as e:
logger.error(f"Error saving message to Redis for session {session_id}: {str(e)}")
return False
async def get_messages(self, session_id: str) -> list:
"""
Retrieve all conversation messages for a session from Redis.
Returns messages ordered by timestamp (oldest first).
Args:
session_id: The session ID
Returns:
List of message dictionaries (parsed from JSON)
"""
if not self.redis:
raise RuntimeError("Redis client not connected")
key = self._messages_key(session_id)
try:
# Get all messages from sorted set (ordered by score/timestamp)
message_strings = await self.redis.zrange(key, 0, -1)
if not message_strings:
logger.debug(f"No messages found in Redis for session: {session_id}")
return []
# Parse JSON strings to dictionaries
messages = []
for msg_str in message_strings:
try:
messages.append(json.loads(msg_str))
except json.JSONDecodeError as e:
logger.error(f"Error parsing message JSON: {str(e)}")
continue
logger.debug(f"Retrieved {len(messages)} messages from Redis for session: {session_id}")
return messages
except Exception as e:
logger.error(f"Error retrieving messages from Redis for session {session_id}: {str(e)}")
return []
# ====== Notification Methods ======
def _notification_key(self, session_id: str) -> str:
"""Generate Redis key for notification session."""
return f"notification:{session_id}"
def _phone_to_notification_key(self, phone: str) -> str:
"""Generate Redis key for phone-to-notification mapping."""
return f"notification:phone_to_notification:{phone}"
async def save_or_append_notification(self, new_entry: NotificationDTO) -> None:
"""
Save or append notification entry to session.
Args:
new_entry: Notification entry to save
Raises:
ValueError: If phone number is missing
"""
if not self.redis:
raise RuntimeError("Redis client not connected")
phone_number = new_entry.telefono
if not phone_number or not phone_number.strip():
raise ValueError("Phone number is required to manage notification entries")
# Use phone number as session ID for notifications
notification_session_id = phone_number
# Get existing session or create new one
existing_session = await self.get_notification_session(notification_session_id)
if existing_session:
# Append to existing session
updated_notifications = existing_session.notificaciones + [new_entry]
updated_session = NotificationSessionDTO(
session_id=notification_session_id,
telefono=phone_number,
fecha_creacion=existing_session.fecha_creacion,
ultima_actualizacion=datetime.now(),
notificaciones=updated_notifications,
)
else:
# Create new session
updated_session = NotificationSessionDTO(
session_id=notification_session_id,
telefono=phone_number,
fecha_creacion=datetime.now(),
ultima_actualizacion=datetime.now(),
notificaciones=[new_entry],
)
# Save to Redis
await self._cache_notification_session(updated_session)
async def _cache_notification_session(
self, session: NotificationSessionDTO
) -> bool:
"""Cache notification session in Redis."""
if not self.redis:
raise RuntimeError("Redis client not connected")
key = self._notification_key(session.sessionId)
phone_key = self._phone_to_notification_key(session.telefono)
try:
# Save notification session
data = session.model_dump_json(by_alias=False)
await self.redis.setex(key, self.notification_ttl, data)
# Save phone-to-session mapping
await self.redis.setex(phone_key, self.notification_ttl, session.sessionId)
logger.debug(f"Cached notification session: {session.sessionId}")
return True
except Exception as e:
logger.error(
f"Error caching notification session {session.sessionId}: {str(e)}"
)
return False
async def get_notification_session(
self, session_id: str
) -> NotificationSessionDTO | None:
"""Retrieve notification session from Redis."""
if not self.redis:
raise RuntimeError("Redis client not connected")
key = self._notification_key(session_id)
data = await self.redis.get(key)
if not data:
logger.debug(f"Notification session not found in Redis: {session_id}")
return None
try:
session_dict = json.loads(data)
session = NotificationSessionDTO.model_validate(session_dict)
logger.info(f"Notification session {session_id} retrieved from Redis")
return session
except Exception as e:
logger.error(
f"Error deserializing notification session {session_id}: {str(e)}"
)
return None
async def get_notification_id_for_phone(self, phone: str) -> str | None:
"""Get notification session ID for a phone number."""
if not self.redis:
raise RuntimeError("Redis client not connected")
key = self._phone_to_notification_key(phone)
session_id = await self.redis.get(key)
if session_id:
logger.info(f"Session ID {session_id} found for phone")
else:
logger.debug("Session ID not found for phone")
return session_id
async def delete_notification_session(self, phone_number: str) -> bool:
"""Delete notification session from Redis."""
if not self.redis:
raise RuntimeError("Redis client not connected")
notification_key = self._notification_key(phone_number)
phone_key = self._phone_to_notification_key(phone_number)
try:
logger.info(f"Deleting notification session for phone {phone_number}")
await self.redis.delete(notification_key)
await self.redis.delete(phone_key)
return True
except Exception as e:
logger.error(
f"Error deleting notification session for phone {phone_number}: {str(e)}"
)
return False

View File

@@ -0,0 +1,5 @@
"""Utilities module."""
from .session_id import SessionIdGenerator
__all__ = ["SessionIdGenerator"]

View File

@@ -0,0 +1,23 @@
"""
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.
Session ID generator utility.
"""
import uuid
class SessionIdGenerator:
"""Generate unique session IDs."""
@staticmethod
def generate() -> str:
"""Generate a new unique session ID."""
return str(uuid.uuid4())
def generate_session_id() -> str:
"""Generate a new unique session ID (convenience function)."""
return SessionIdGenerator.generate()

View File

@@ -1,18 +0,0 @@
/*
* 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.
*/
package com.example;
import com.google.cloud.spring.data.firestore.repository.config.EnableReactiveFirestoreRepositories;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
@EnableReactiveFirestoreRepositories(basePackages = "com.example.repository")
public class Orchestrator {
public static void main(String[] args) {
SpringApplication.run(Orchestrator.class, args);
}
}

View File

@@ -1,20 +0,0 @@
/*
* 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.
*/
package com.example.config;
import com.google.cloud.dlp.v2.DlpServiceClient;
import java.io.IOException;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class DlpConfig {
@Bean(destroyMethod = "close")
public DlpServiceClient dlpServiceClient() throws IOException {
return DlpServiceClient.create();
}
}

View File

@@ -1,44 +0,0 @@
/*
* 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.
*/
package com.example.config;
import com.google.genai.Client;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.io.IOException;
/**
* Spring configuration class for initializing the Google Gen AI Client.
* It uses properties from the application's configuration to create a
* singleton `Client` bean for interacting with the Gemini model, ensuring
* proper resource management by specifying a destroy method.
*/
@Configuration
public class GeminiConfig {
private static final Logger logger = LoggerFactory.getLogger(GeminiConfig.class);
@Value("${google.cloud.project}")
private String projectId;
@Value("${google.cloud.location}")
private String location;
@Bean(destroyMethod = "close")
public Client geminiClient() throws IOException {
logger.info("Initializing Google Gen AI Client. Project: {}, Location: {}", projectId, location);
return Client.builder()
.project(projectId)
.location(location)
.vertexAI(true)
.build();
}
}

View File

@@ -1,33 +0,0 @@
/*
* 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.
*/
package com.example.config;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Info;
import io.swagger.v3.oas.models.info.License;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* Spring configuration class for customizing OpenAPI (Swagger) documentation.
* It defines a single bean to configure the API's title, version, description,
* and license, providing a structured and user-friendly documentation page.
*/
@Configuration
public class OpenApiConfig {
@Bean
public OpenAPI customOpenAPI() {
return new OpenAPI()
.info(new Info()
.title("Google Middleware API")
.version("1.0")
.description("API documentation. " +
"It provides functionalities for user management, file storage, and more.")
.termsOfService("http://swagger.io/terms/")
.license(new License().name("Apache 2.0").url("http://springdoc.org")));
}
}

View File

@@ -1,51 +0,0 @@
/*
* 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.
*/
package com.example.config;
import com.example.dto.dialogflow.conversation.ConversationMessageDTO;
import com.example.dto.dialogflow.conversation.ConversationSessionDTO;
import com.example.dto.dialogflow.notification.NotificationSessionDTO;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.ReactiveRedisConnectionFactory;
import org.springframework.data.redis.core.ReactiveRedisTemplate;
import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.RedisSerializationContext;
import org.springframework.data.redis.serializer.StringRedisSerializer;
@Configuration
public class RedisConfig {
@Bean
public ReactiveRedisTemplate<String, ConversationSessionDTO> reactiveRedisTemplate(ReactiveRedisConnectionFactory factory, ObjectMapper objectMapper) {
Jackson2JsonRedisSerializer<ConversationSessionDTO> serializer = new Jackson2JsonRedisSerializer<>(objectMapper, ConversationSessionDTO.class);
RedisSerializationContext.RedisSerializationContextBuilder<String, ConversationSessionDTO> builder = RedisSerializationContext.newSerializationContext(new StringRedisSerializer());
RedisSerializationContext<String, ConversationSessionDTO> context = builder.value(serializer).build();
return new ReactiveRedisTemplate<>(factory, context);
}
@Bean
public ReactiveRedisTemplate<String, NotificationSessionDTO> reactiveNotificationRedisTemplate(ReactiveRedisConnectionFactory factory, ObjectMapper objectMapper) {
Jackson2JsonRedisSerializer<NotificationSessionDTO> serializer = new Jackson2JsonRedisSerializer<>(objectMapper, NotificationSessionDTO.class);
RedisSerializationContext.RedisSerializationContextBuilder<String, NotificationSessionDTO> builder = RedisSerializationContext.newSerializationContext(new StringRedisSerializer());
RedisSerializationContext<String, NotificationSessionDTO> context = builder.value(serializer).build();
return new ReactiveRedisTemplate<>(factory, context);
}
@Bean
public ReactiveRedisTemplate<String, ConversationMessageDTO> reactiveMessageRedisTemplate(ReactiveRedisConnectionFactory factory, ObjectMapper objectMapper) {
Jackson2JsonRedisSerializer<ConversationMessageDTO> serializer = new Jackson2JsonRedisSerializer<>(objectMapper, ConversationMessageDTO.class);
RedisSerializationContext.RedisSerializationContextBuilder<String, ConversationMessageDTO> builder = RedisSerializationContext.newSerializationContext(new StringRedisSerializer());
RedisSerializationContext<String, ConversationMessageDTO> context = builder.value(serializer).build();
return new ReactiveRedisTemplate<>(factory, context);
}
@Bean
public ReactiveRedisTemplate<String, String> reactiveStringRedisTemplate(ReactiveRedisConnectionFactory factory) {
return new ReactiveRedisTemplate<>(factory, RedisSerializationContext.string());
}
}

View File

@@ -1,42 +0,0 @@
/*
* 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.
*/
package com.example.controller;
import com.example.dto.dialogflow.base.DetectIntentResponseDTO;
import com.example.dto.dialogflow.conversation.ExternalConvRequestDTO;
import com.example.mapper.conversation.ExternalConvRequestMapper;
import com.example.service.conversation.ConversationManagerService;
import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Mono;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@RestController
@RequestMapping("/api/v1/dialogflow")
public class ConversationController {
private static final Logger logger = LoggerFactory.getLogger(ConversationController.class);
private final ConversationManagerService conversationManagerService;
public ConversationController(ConversationManagerService conversationManagerService,
ExternalConvRequestMapper externalRequestToDialogflowMapper) {
this.conversationManagerService = conversationManagerService;
}
@PostMapping("/detect-intent")
public Mono<DetectIntentResponseDTO> detectIntent(@Valid @RequestBody ExternalConvRequestDTO request) {
return conversationManagerService.manageConversation(request)
.doOnSuccess(response -> logger.info("Successfully processed direct Dialogflow request"))
.doOnError(error -> logger.error("Error processing direct Dialogflow request: {}", error.getMessage(), error));
}
}

View File

@@ -1,29 +0,0 @@
package com.example.controller;
import com.example.service.base.DataPurgeService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Mono;
@RestController
@RequestMapping("/api/v1/data-purge")
public class DataPurgeController {
private static final Logger logger = LoggerFactory.getLogger(DataPurgeController.class);
private final DataPurgeService dataPurgeService;
public DataPurgeController(DataPurgeService dataPurgeService) {
this.dataPurgeService = dataPurgeService;
}
@DeleteMapping("/all")
public Mono<Void> purgeAllData() {
logger.warn("Received request to purge all data. This is a destructive operation.");
return dataPurgeService.purgeAllData()
.doOnSuccess(voidResult -> logger.info("Successfully purged all data."))
.doOnError(error -> logger.error("Error purging all data.", error));
}
}

View File

@@ -1,85 +0,0 @@
/*
* 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.
*/
package com.example.controller;
import com.example.dto.llm.webhook.WebhookRequestDTO;
import com.example.dto.llm.webhook.SessionInfoDTO;
import com.example.dto.llm.webhook.WebhookResponseDTO;
import com.example.service.llm.LlmResponseTunerService;
import java.util.HashMap;
import java.util.Map;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Mono;
@RestController
@RequestMapping("/api/v1/llm")
public class LlmResponseTunerController {
private static final Logger logger = LoggerFactory.getLogger(LlmResponseTunerController.class);
private final LlmResponseTunerService llmResponseTunerService;
public LlmResponseTunerController(LlmResponseTunerService llmResponseTunerService) {
this.llmResponseTunerService = llmResponseTunerService;
}
@PostMapping("/tune-response")
public Mono<WebhookResponseDTO> tuneResponse(@RequestBody WebhookRequestDTO request) {
String uuid = (String) request.getSessionInfo().getParameters().get("uuid");
return llmResponseTunerService
.getValue(uuid)
.map(
value -> {
Map<String, Object> parameters = new HashMap<>();
parameters.put("webhook_success", true);
parameters.put("response", value);
SessionInfoDTO sessionInfo = new SessionInfoDTO(parameters);
return new WebhookResponseDTO(sessionInfo);
})
.defaultIfEmpty(createErrorResponse("No response found for the given UUID.", false))
.onErrorResume(
e -> {
logger.error("Error tuning response: {}", e.getMessage());
return Mono.just(
createErrorResponse("An internal error occurred.", true));
});
}
private WebhookResponseDTO createErrorResponse(String errorMessage, boolean isError) {
Map<String, Object> parameters = new HashMap<>();
parameters.put("webhook_success", false);
parameters.put("error_message", errorMessage);
SessionInfoDTO sessionInfo = new SessionInfoDTO(parameters);
return new WebhookResponseDTO(sessionInfo);
}
@ExceptionHandler(Exception.class)
public ResponseEntity<Map<String, String>> handleException(Exception e) {
logger.error("An unexpected error occurred: {}", e.getMessage());
Map<String, String> response = new HashMap<>();
response.put("error", "Internal Server Error");
response.put("message", "An unexpected error occurred. Please try again later.");
return new ResponseEntity<>(response, HttpStatus.INTERNAL_SERVER_ERROR);
}
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<Map<String, String>> handleIllegalArgumentException(
IllegalArgumentException e) {
logger.error("Bad request: {}", e.getMessage());
Map<String, String> response = new HashMap<>();
response.put("error", "Bad Request");
response.put("message", e.getMessage());
return new ResponseEntity<>(response, HttpStatus.BAD_REQUEST);
}
}

View File

@@ -1,38 +0,0 @@
/*
* 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.
*/
package com.example.controller;
import com.example.dto.dialogflow.notification.ExternalNotRequestDTO;
import com.example.service.notification.NotificationManagerService;
import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Mono;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@RestController
@RequestMapping("/api/v1/dialogflow")
public class NotificationController {
private static final Logger logger = LoggerFactory.getLogger(ConversationController.class);
private final NotificationManagerService notificationManagerService;
public NotificationController(NotificationManagerService notificationManagerService) {
this.notificationManagerService = notificationManagerService;
}
@PostMapping("/notification")
public Mono<Void> processNotification(@Valid @RequestBody ExternalNotRequestDTO request) {
return notificationManagerService.processNotification(request)
.doOnSuccess(response -> logger.info("Successfully processed direct Dialogflow request"))
.doOnError(error -> logger.error("Error processing direct Dialogflow request: {}", error.getMessage(), error))
.then();
}
}

View File

@@ -1,38 +0,0 @@
/*
* 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.
*/
package com.example.controller;
import com.example.dto.dialogflow.base.DetectIntentResponseDTO;
import com.example.dto.quickreplies.QuickReplyScreenRequestDTO;
import com.example.service.quickreplies.QuickRepliesManagerService;
import jakarta.validation.Valid;
import java.util.Map;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Mono;
@RestController
@RequestMapping("/api/v1/quick-replies")
public class QuickRepliesController {
private static final Logger logger = LoggerFactory.getLogger(QuickRepliesController.class);
private final QuickRepliesManagerService quickRepliesManagerService;
public QuickRepliesController(QuickRepliesManagerService quickRepliesManagerService) {
this.quickRepliesManagerService = quickRepliesManagerService;
}
@PostMapping("/screen")
public Mono<DetectIntentResponseDTO> startSessionAndGetReplies(@Valid @RequestBody QuickReplyScreenRequestDTO request) {
return quickRepliesManagerService.startQuickReplySession(request)
.doOnSuccess(response -> logger.info("Successfully processed quick reply request"))
.doOnError(error -> logger.error("Error processing quick reply request: {}", error.getMessage(), error));
}
}

View File

@@ -1,40 +0,0 @@
/*
* 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.
*/
package com.example.dto.dialogflow.base;
import com.example.dto.dialogflow.conversation.QueryInputDTO;
import com.example.dto.dialogflow.conversation.QueryParamsDTO;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
@JsonIgnoreProperties(ignoreUnknown = true)
public record DetectIntentRequestDTO(
@JsonProperty("queryInput") QueryInputDTO queryInput,
@JsonProperty("queryParams") QueryParamsDTO queryParams
) {
public DetectIntentRequestDTO withParameter(String key, Object value) {
// Create a new QueryParamsDTO with the updated session parameter
QueryParamsDTO updatedQueryParams = this.queryParams().withSessionParameter(key, value);
// Return a new DetectIntentRequestDTO instance with the updated QueryParamsDTO
return new DetectIntentRequestDTO(
this.queryInput(),
updatedQueryParams
);
}
public DetectIntentRequestDTO withParameters(java.util.Map<String, Object> parameters) {
// Create a new QueryParamsDTO with the updated session parameters
QueryParamsDTO updatedQueryParams = this.queryParams().withSessionParameters(parameters);
// Return a new DetectIntentRequestDTO instance with the updated QueryParamsDTO
return new DetectIntentRequestDTO(
this.queryInput(),
updatedQueryParams
);
}
}

View File

@@ -1,23 +0,0 @@
/*
* 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.
*/
package com.example.dto.dialogflow.base;
import com.example.dto.dialogflow.conversation.QueryResultDTO;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.example.dto.quickreplies.QuickReplyDTO;
import com.fasterxml.jackson.annotation.JsonInclude;
@JsonInclude(JsonInclude.Include.NON_NULL)
public record DetectIntentResponseDTO(
@JsonProperty("responseId") String responseId,
@JsonProperty("queryResult") QueryResultDTO queryResult,
@JsonProperty("quick_replies") QuickReplyDTO quickReplies
) {
public DetectIntentResponseDTO(String responseId, QueryResultDTO queryResult) {
this(responseId, queryResult, null);
}
}

View File

@@ -1,13 +0,0 @@
/*
* 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.
*/
package com.example.dto.dialogflow.conversation;
public record ConversationContext(
String userId,
String sessionId,
String userMessageText,
String primaryPhoneNumber
) {}

View File

@@ -1,110 +0,0 @@
/*
* 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.
*/
package com.example.dto.dialogflow.conversation;
import com.fasterxml.jackson.annotation.JsonInclude;
import java.time.Instant;
import java.util.Map;
@JsonInclude(JsonInclude.Include.NON_NULL)
public record ConversationEntryDTO(
ConversationEntryEntity entity,
ConversationEntryType type,
Instant timestamp,
String text,
Map<String, Object> parameters,
String canal
) {
public static ConversationEntryDTO forUser(String text) {
return new ConversationEntryDTO(
ConversationEntryEntity.USUARIO,
ConversationEntryType.CONVERSACION,
Instant.now(),
text,
null,
null);
}
public static ConversationEntryDTO forUser(String text, Map<String, Object> parameters) {
return new ConversationEntryDTO(
ConversationEntryEntity.USUARIO,
ConversationEntryType.CONVERSACION,
Instant.now(),
text,
parameters,
null);
}
public static ConversationEntryDTO forAgent(QueryResultDTO agentQueryResult) {
String fulfillmentText = (agentQueryResult != null && agentQueryResult.responseText() != null) ? agentQueryResult.responseText() : "";
Map<String, Object> parameters = (agentQueryResult != null) ? agentQueryResult.parameters() : null;
return new ConversationEntryDTO(
ConversationEntryEntity.AGENTE,
ConversationEntryType.CONVERSACION,
Instant.now(),
fulfillmentText,
parameters,
null
);
}
public static ConversationEntryDTO forAgentWithMessage(String text) {
return new ConversationEntryDTO(
ConversationEntryEntity.AGENTE,
ConversationEntryType.CONVERSACION,
Instant.now(),
text,
null,
null
);
}
public static ConversationEntryDTO forSystem(String text) {
return new ConversationEntryDTO(
ConversationEntryEntity.SISTEMA,
ConversationEntryType.CONVERSACION,
Instant.now(),
text,
null,
null
);
}
public static ConversationEntryDTO forSystem(String text, Map<String, Object> parameters) {
return new ConversationEntryDTO(
ConversationEntryEntity.SISTEMA,
ConversationEntryType.CONVERSACION,
Instant.now(),
text,
parameters,
null
);
}
public static ConversationEntryDTO forLlmConversation(String text) {
return new ConversationEntryDTO(
ConversationEntryEntity.LLM,
ConversationEntryType.CONVERSACION,
Instant.now(),
text,
null,
null
);
}
public static ConversationEntryDTO forLlmConversation(String text, Map<String, Object> parameters) {
return new ConversationEntryDTO(
ConversationEntryEntity.LLM,
ConversationEntryType.CONVERSACION,
Instant.now(),
text,
parameters,
null
);
}
}

View File

@@ -1,13 +0,0 @@
/*
* 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.
*/
package com.example.dto.dialogflow.conversation;
public enum ConversationEntryEntity {
USUARIO,
AGENTE,
SISTEMA,
LLM
}

View File

@@ -1,12 +0,0 @@
/*
* 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.
*/
package com.example.dto.dialogflow.conversation;
public enum ConversationEntryType {
INICIO,
CONVERSACION,
LLM
}

View File

@@ -1,20 +0,0 @@
/*
* 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.
*/
package com.example.dto.dialogflow.conversation;
import com.fasterxml.jackson.annotation.JsonInclude;
import java.time.Instant;
import java.util.Map;
@JsonInclude(JsonInclude.Include.NON_NULL)
public record ConversationMessageDTO(
MessageType type,
Instant timestamp,
String text,
Map<String, Object> parameters,
String canal
) {
}

View File

@@ -1,53 +0,0 @@
/*
* 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.
*/
package com.example.dto.dialogflow.conversation;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonInclude.Include;
import java.time.Instant;
@JsonIgnoreProperties(ignoreUnknown = true)
@JsonInclude(Include.NON_NULL)
public record ConversationSessionDTO(
String sessionId,
String userId,
String telefono,
Instant createdAt,
Instant lastModified,
String lastMessage,
String pantallaContexto
) {
public ConversationSessionDTO(String sessionId, String userId, String telefono, Instant createdAt, Instant lastModified, String lastMessage, String pantallaContexto) {
this.sessionId = sessionId;
this.userId = userId;
this.telefono = telefono;
this.createdAt = createdAt;
this.lastModified = lastModified;
this.lastMessage = lastMessage;
this.pantallaContexto = pantallaContexto;
}
public static ConversationSessionDTO create(String sessionId, String userId, String telefono) {
Instant now = Instant.now();
return new ConversationSessionDTO(sessionId, userId, telefono, now, now, null, null);
}
public ConversationSessionDTO withLastMessage(String lastMessage) {
return new ConversationSessionDTO(this.sessionId, this.userId, this.telefono, this.createdAt, Instant.now(), lastMessage, this.pantallaContexto);
}
public ConversationSessionDTO withTelefono(String newTelefono) {
if (newTelefono != null && !newTelefono.equals(this.telefono)) {
return new ConversationSessionDTO(this.sessionId, this.userId, newTelefono, this.createdAt, this.lastModified, this.lastMessage, this.pantallaContexto);
}
return this;
}
public ConversationSessionDTO withPantallaContexto(String pantallaContexto) {
return new ConversationSessionDTO(this.sessionId, this.userId, this.telefono, this.createdAt, this.lastModified, this.lastMessage, pantallaContexto);
}
}

View File

@@ -1,20 +0,0 @@
/*
* 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.
*/
package com.example.dto.dialogflow.conversation;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
@JsonIgnoreProperties(ignoreUnknown = true)
public record ExternalConvRequestDTO(
@JsonProperty("mensaje") String message,
@JsonProperty("usuario") UsuarioDTO user,
@JsonProperty("canal") String channel,
@JsonProperty("tipo") ConversationEntryType tipo,
@JsonProperty("pantallaContexto") String pantallaContexto //optional field for quick-replies
) {
public ExternalConvRequestDTO {}
}

View File

@@ -1,13 +0,0 @@
/*
* 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.
*/
package com.example.dto.dialogflow.conversation;
public enum MessageType {
USER,
AGENT,
SYSTEM,
LLM
}

View File

@@ -1,16 +0,0 @@
/*
* 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.
*/
package com.example.dto.dialogflow.conversation;
import com.example.dto.dialogflow.notification.EventInputDTO;
public record QueryInputDTO(
TextInputDTO text, // Can be null if using event
EventInputDTO event,
String languageCode // REQUIRED for both text and event inputs
) {}

View File

@@ -1,34 +0,0 @@
/*
* 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.
*/
package com.example.dto.dialogflow.conversation;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
@JsonIgnoreProperties(ignoreUnknown = true)
public record QueryParamsDTO(
@JsonProperty("parameters") Map<String, Object> parameters) {
public QueryParamsDTO {
parameters = Objects.requireNonNullElseGet(parameters, HashMap::new);
parameters = new HashMap<>(parameters);
}
public QueryParamsDTO withSessionParameter(String key, Object value) {
Map<String, Object> updatedParams = new HashMap<>(this.parameters());
updatedParams.put(key, value);
return new QueryParamsDTO(updatedParams);
}
public QueryParamsDTO withSessionParameters(Map<String, Object> parameters) {
Map<String, Object> updatedParams = new HashMap<>(this.parameters());
updatedParams.putAll(parameters);
return new QueryParamsDTO(updatedParams);
}
}

View File

@@ -1,14 +0,0 @@
/*
* 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.
*/
package com.example.dto.dialogflow.conversation;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.Map;
public record QueryResultDTO(
@JsonProperty("responseText") String responseText,
@JsonProperty("parameters") Map<String, Object> parameters
) {}

View File

@@ -1,8 +0,0 @@
/*
* 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.
*/
package com.example.dto.dialogflow.conversation;
public record TextInputDTO(String text) {}

View File

@@ -1,14 +0,0 @@
/*
* 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.
*/
package com.example.dto.dialogflow.conversation;
import com.fasterxml.jackson.annotation.JsonProperty;
import jakarta.validation.constraints.NotBlank;
public record UsuarioDTO(
@JsonProperty("telefono") @NotBlank String telefono,
@JsonProperty("nickname") String nickname
) {}

View File

@@ -1,10 +0,0 @@
/*
* 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.
*/
package com.example.dto.dialogflow.notification;
public record EventInputDTO(
String event
) {}

View File

@@ -1,18 +0,0 @@
/*
* 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.
*/
package com.example.dto.dialogflow.notification;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
@JsonIgnoreProperties(ignoreUnknown = true)
public record ExternalNotRequestDTO(
@JsonProperty("texto") String text,
@JsonProperty("telefono") String phoneNumber,
@JsonProperty("parametrosOcultos") java.util.Map<String, String> hiddenParameters
) {
public ExternalNotRequestDTO {
}
}

View File

@@ -1,33 +0,0 @@
/*
* 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.
*/
package com.example.dto.dialogflow.notification;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import java.time.Instant;
import java.util.Map;
import java.util.Objects;
/**
* Represents a notification record to be stored in Firestore and cached in
* Redis.
*/
@JsonIgnoreProperties(ignoreUnknown = true) // Ignorar campos adicionales durante la deserialización
public record NotificationDTO(
String idNotificacion, // ID único para esta notificación (ej. el sessionId usado con Dialogflow)
String telefono,
Instant timestampCreacion, // Momento en que la notificación fue procesada
String texto, // 'texto' original de NotificationRequestDTO (si aplica)
String nombreEventoDialogflow, // Nombre del evento enviado a Dialogflow (ej. "tu Estado de cuenta listo")
String codigoIdiomaDialogflow, // Código de idioma usado para el evento
Map<String, Object> parametros, // Parámetros de sesión finales después del procesamiento de// Dialogflow
String status
) {
public NotificationDTO {
Objects.requireNonNull(idNotificacion, "Notification ID cannot be null.");
Objects.requireNonNull(timestampCreacion, "Notification timestamp cannot be null.");
Objects.requireNonNull(nombreEventoDialogflow, "Dialogflow event name cannot be null.");
Objects.requireNonNull(codigoIdiomaDialogflow, "Dialogflow language code cannot be null.");
}
}

View File

@@ -1,24 +0,0 @@
// src/main/java/com/example/dto/dialogflow/notification/NotificationSessionDTO.java
package com.example.dto.dialogflow.notification;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import java.time.Instant;
import java.util.List;
import java.util.Objects;
@JsonIgnoreProperties(ignoreUnknown = true)
public record NotificationSessionDTO(
String sessionId, // The unique session identifier (e.g., the phone number)
String telefono, // The phone number for this session
Instant fechaCreacion, // When the session was first created
Instant ultimaActualizacion, // When the session was last updated
List<NotificationDTO> notificaciones // List of individual notification events
) {
public NotificationSessionDTO {
Objects.requireNonNull(sessionId, "Session ID cannot be null.");
Objects.requireNonNull(telefono, "Phone number cannot be null.");
Objects.requireNonNull(fechaCreacion, "Creation timestamp cannot be null.");
Objects.requireNonNull(ultimaActualizacion, "Last updated timestamp cannot be null.");
Objects.requireNonNull(notificaciones, "Notifications list cannot be null.");
}
}

View File

@@ -1,23 +0,0 @@
package com.example.dto.llm.webhook;
import java.util.Map;
public class SessionInfoDTO {
private Map<String, Object> parameters;
public SessionInfoDTO() {
}
public SessionInfoDTO(Map<String, Object> parameters) {
this.parameters = parameters;
}
public Map<String, Object> getParameters() {
return parameters;
}
public void setParameters(Map<String, Object> parameters) {
this.parameters = parameters;
}
}

View File

@@ -1,17 +0,0 @@
package com.example.dto.llm.webhook;
public class WebhookRequestDTO {
private SessionInfoDTO sessionInfo;
public WebhookRequestDTO() {
}
public SessionInfoDTO getSessionInfo() {
return sessionInfo;
}
public void setSessionInfo(SessionInfoDTO sessionInfo) {
this.sessionInfo = sessionInfo;
}
}

View File

@@ -1,24 +0,0 @@
package com.example.dto.llm.webhook;
import com.fasterxml.jackson.annotation.JsonProperty;
public class WebhookResponseDTO {
@JsonProperty("sessionInfo")
private SessionInfoDTO sessionInfo;
public WebhookResponseDTO() {
}
public WebhookResponseDTO(SessionInfoDTO sessionInfo) {
this.sessionInfo = sessionInfo;
}
public SessionInfoDTO getSessionInfo() {
return sessionInfo;
}
public void setSessionInfo(SessionInfoDTO sessionInfo) {
this.sessionInfo = sessionInfo;
}
}

View File

@@ -1,7 +0,0 @@
package com.example.dto.quickreplies;
import com.fasterxml.jackson.annotation.JsonProperty;
public record QuestionDTO(
@JsonProperty("titulo") String titulo,
@JsonProperty("descripcion") String descripcion,
@JsonProperty("respuesta") String respuesta
) {}

View File

@@ -1,10 +0,0 @@
package com.example.dto.quickreplies;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.List;
public record QuickReplyDTO(
@JsonProperty("header") String header,
@JsonProperty("body") String body,
@JsonProperty("button") String button,
@JsonProperty("header_section") String headerSection,
@JsonProperty("preguntas") List<QuestionDTO> preguntas
) {}

View File

@@ -1,14 +0,0 @@
package com.example.dto.quickreplies;
import com.example.dto.dialogflow.conversation.ConversationEntryType;
import com.example.dto.dialogflow.conversation.UsuarioDTO;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
@JsonIgnoreProperties(ignoreUnknown = true)
public record QuickReplyScreenRequestDTO(
@JsonProperty("usuario") UsuarioDTO user,
@JsonProperty("canal") String channel,
@JsonProperty("tipo") ConversationEntryType tipo,
@JsonProperty("pantallaContexto") String pantallaContexto
) {}

View File

@@ -1,17 +0,0 @@
/*
* 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.
*/
package com.example.exception;
public class DialogflowClientException extends RuntimeException {
public DialogflowClientException(String message) {
super(message);
}
public DialogflowClientException(String message, Throwable cause) {
super(message, cause);
}
}

View File

@@ -1,17 +0,0 @@
/*
* 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.
*/
package com.example.exception;
public class FirestorePersistenceException extends RuntimeException {
public FirestorePersistenceException(String message) {
super(message);
}
public FirestorePersistenceException(String message, Throwable cause) {
super(message, cause);
}
}

View File

@@ -1,17 +0,0 @@
/*
* 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.
*/
package com.example.exception;
public class GeminiClientException extends Exception {
public GeminiClientException(String message) {
super(message);
}
public GeminiClientException(String message, Throwable cause) {
super(message, cause);
}
}

View File

@@ -1,49 +0,0 @@
/*
* 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.
*/
package com.example.exception;
import java.util.HashMap;
import java.util.Map;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
@ControllerAdvice
public class GlobalExceptionHandler {
private static final Logger logger = LoggerFactory.getLogger(GlobalExceptionHandler.class);
@ExceptionHandler(DialogflowClientException.class)
public ResponseEntity<Map<String, String>> handleDialogflowClientException(
DialogflowClientException ex) {
Map<String, String> error = new HashMap<>();
error.put("error", "Error communicating with Dialogflow");
error.put("message", ex.getMessage());
logger.error("DialogflowClientException: {}", ex.getMessage());
return new ResponseEntity<>(error, HttpStatus.SERVICE_UNAVAILABLE);
}
@ExceptionHandler(GeminiClientException.class)
public ResponseEntity<Map<String, String>> handleGeminiClientException(GeminiClientException ex) {
Map<String, String> error = new HashMap<>();
error.put("error", "Error communicating with Gemini");
error.put("message", ex.getMessage());
logger.error("GeminiClientException: {}", ex.getMessage());
return new ResponseEntity<>(error, HttpStatus.SERVICE_UNAVAILABLE);
}
@ExceptionHandler(Exception.class)
public ResponseEntity<Map<String, String>> handleAllExceptions(Exception ex) {
Map<String, String> error = new HashMap<>();
error.put("error", "Internal Server Error");
error.put("message", ex.getMessage());
logger.error("An unexpected error occurred: {}", ex.getMessage(), ex);
return new ResponseEntity<>(error, HttpStatus.INTERNAL_SERVER_ERROR);
}
}

View File

@@ -1,32 +0,0 @@
/*
* 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.
*/
package com.example.mapper.conversation;
import com.example.dto.dialogflow.conversation.ConversationEntryDTO;
import com.example.dto.dialogflow.conversation.ConversationMessageDTO;
import com.example.dto.dialogflow.conversation.MessageType;
import org.springframework.stereotype.Component;
@Component
public class ConversationEntryMapper {
public ConversationMessageDTO toConversationMessageDTO(ConversationEntryDTO entry) {
MessageType type = switch (entry.entity()) {
case USUARIO -> MessageType.USER;
case AGENTE -> MessageType.AGENT;
case SISTEMA -> MessageType.SYSTEM;
case LLM -> MessageType.LLM;
};
return new ConversationMessageDTO(
type,
entry.timestamp(),
entry.text(),
entry.parameters(),
entry.canal()
);
}
}

View File

@@ -1,50 +0,0 @@
/*
* 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.
*/
package com.example.mapper.conversation;
import com.google.cloud.Timestamp;
import com.example.dto.dialogflow.conversation.ConversationMessageDTO;
import com.example.dto.dialogflow.conversation.MessageType;
import org.springframework.stereotype.Component;
import java.time.Instant;
import java.util.HashMap;
import java.util.Map;
@Component
public class ConversationMessageMapper {
public Map<String, Object> toMap(ConversationMessageDTO message) {
Map<String, Object> map = new HashMap<>();
map.put("entidad", message.type().name());
map.put("tiempo", message.timestamp());
map.put("mensaje", message.text());
if (message.parameters() != null) {
map.put("parametros", message.parameters());
}
if (message.canal() != null) {
map.put("canal", message.canal());
}
return map;
}
public ConversationMessageDTO fromMap(Map<String, Object> map) {
Object timeObject = map.get("tiempo");
Instant timestamp = null;
if (timeObject instanceof Timestamp) {
timestamp = ((Timestamp) timeObject).toDate().toInstant();
} else if (timeObject instanceof Instant) {
timestamp = (Instant) timeObject;
}
return new ConversationMessageDTO(
MessageType.valueOf((String) map.get("entidad")),
timestamp,
(String) map.get("mensaje"),
(Map<String, Object>) map.get("parametros"),
(String) map.get("canal")
);
}
}

View File

@@ -1,102 +0,0 @@
/*
* 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.
*/
package com.example.mapper.conversation;
import com.example.dto.dialogflow.base.DetectIntentRequestDTO;
import com.example.dto.dialogflow.conversation.QueryInputDTO;
import com.example.util.ProtobufUtil;
import com.google.cloud.dialogflow.cx.v3.EventInput;
import com.google.cloud.dialogflow.cx.v3.DetectIntentRequest;
import com.google.cloud.dialogflow.cx.v3.QueryInput;
import com.google.cloud.dialogflow.cx.v3.QueryParameters;
import com.google.cloud.dialogflow.cx.v3.TextInput;
import com.google.protobuf.Struct;
import com.google.protobuf.Value;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import java.util.Map;
import java.util.Objects;
/**
* Spring component responsible for mapping a custom `DetectIntentRequestDTO`
* into a Dialogflow CX `DetectIntentRequest.Builder`. It handles the conversion
* of user text or event inputs and the serialization of custom session parameters,
* ensuring the data is in the correct Protobuf format for API communication.
*/
@Component
public class DialogflowRequestMapper {
private static final Logger logger = LoggerFactory.getLogger(DialogflowRequestMapper.class);
@org.springframework.beans.factory.annotation.Value("${dialogflow.default-language-code:es}")
String defaultLanguageCode;
public DetectIntentRequest.Builder mapToDetectIntentRequestBuilder(DetectIntentRequestDTO requestDto) {
Objects.requireNonNull(requestDto, "DetectIntentRequestDTO cannot be null for mapping.");
logger.debug(
"Building partial Dialogflow CX DetectIntentRequest Protobuf Builder from DTO (only QueryInput and QueryParams).");
QueryInput.Builder queryInputBuilder = QueryInput.newBuilder();
QueryInputDTO queryInputDTO = requestDto.queryInput();
String languageCodeToSet = (queryInputDTO.languageCode() != null
&& !queryInputDTO.languageCode().trim().isEmpty())
? queryInputDTO.languageCode()
: defaultLanguageCode;
queryInputBuilder.setLanguageCode(languageCodeToSet);
logger.debug("Setting languageCode for QueryInput to: {}", languageCodeToSet);
if (queryInputDTO.text() != null && queryInputDTO.text().text() != null
&& !queryInputDTO.text().text().trim().isEmpty()) {
queryInputBuilder.setText(TextInput.newBuilder()
.setText(queryInputDTO.text().text())
.build());
logger.debug("Mapped text input for QueryInput: '{}'", queryInputDTO.text().text());
} else if (queryInputDTO.event() != null && queryInputDTO.event().event() != null
&& !queryInputDTO.event().event().trim().isEmpty()) {
queryInputBuilder.setEvent(EventInput.newBuilder()
.setEvent(queryInputDTO.event().event())
.build());
logger.debug("Mapped event input for QueryInput: '{}'", queryInputDTO.event().event());
} else {
logger.error("Dialogflow query input (either text or event) is required and must not be empty.");
throw new IllegalArgumentException("Dialogflow query input (either text or event) is required.");
}
QueryParameters.Builder queryParametersBuilder = QueryParameters.newBuilder();
Struct.Builder paramsStructBuilder = Struct.newBuilder();
if (requestDto.queryParams() != null && requestDto.queryParams().parameters() != null) {
for (Map.Entry<String, Object> entry : requestDto.queryParams().parameters().entrySet()) {
Value protobufValue = ProtobufUtil.convertJavaObjectToProtobufValue(entry.getValue());
paramsStructBuilder.putFields(entry.getKey(), protobufValue);
logger.debug("Added session parameter from DTO queryParams: Key='{}', Value='{}'",
entry.getKey(),entry.getValue());
}
}
if (paramsStructBuilder.getFieldsCount() > 0) {
queryParametersBuilder.setParameters(paramsStructBuilder.build());
logger.debug("All custom session parameters added to Protobuf request builder.");
} else {
logger.debug("No custom session parameters to add to Protobuf request.");
}
DetectIntentRequest.Builder detectIntentRequestBuilder = DetectIntentRequest.newBuilder()
.setQueryInput(queryInputBuilder.build());
if (queryParametersBuilder.hasParameters()) {
detectIntentRequestBuilder.setQueryParams(queryParametersBuilder.build());
}
logger.debug("Finished building partial DetectIntentRequest Protobuf Builder.");
return detectIntentRequestBuilder;
}
}

View File

@@ -1,100 +0,0 @@
/*
* 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.
*/
package com.example.mapper.conversation;
import com.google.cloud.dialogflow.cx.v3.QueryResult;
import com.google.cloud.dialogflow.cx.v3.ResponseMessage;
import com.google.cloud.dialogflow.cx.v3.DetectIntentResponse;
import com.example.dto.dialogflow.base.DetectIntentResponseDTO;
import com.example.dto.dialogflow.conversation.QueryResultDTO;
import com.example.util.ProtobufUtil;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.stream.Collectors;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
/**
* Spring component responsible for mapping a Dialogflow CX API response
* (`DetectIntentResponse`) to a simplified custom DTO (`DetectIntentResponseDTO`).
* It extracts and consolidates the fulfillment text, and converts Protobuf
* session parameters into standard Java objects, providing a clean and
* decoupled interface for consuming Dialogflow results.
*/
@Component
public class DialogflowResponseMapper {
private static final Logger logger = LoggerFactory.getLogger(DialogflowResponseMapper.class);
public DetectIntentResponseDTO mapFromDialogflowResponse(DetectIntentResponse response, String sessionId) {
logger.info("Starting mapping of Dialogflow DetectIntentResponse for session: {}", sessionId);
String responseId = response.getResponseId();
QueryResult dfQueryResult = response.getQueryResult();
logger.debug("Extracted QueryResult object for session: {}", sessionId);
StringBuilder responseTextBuilder = new StringBuilder();
if (dfQueryResult.getResponseMessagesList().isEmpty()) {
logger.debug("No response messages found in QueryResult for session: {}", sessionId);
}
for (ResponseMessage message : dfQueryResult.getResponseMessagesList()) {
if (message.hasText()) {
logger.debug("Processing text response message for session: {}", sessionId);
for (String text : message.getText().getTextList()) {
if (responseTextBuilder.length() > 0) {
responseTextBuilder.append(" ");
}
responseTextBuilder.append(text);
logger.debug("Appended text segment: '{}' to fulfillment text for session: {}", text, sessionId);
}
} else {
logger.debug("Skipping non-text response message type: {} for session: {}", message.getMessageCase(), sessionId);
}
}
String responseText = responseTextBuilder.toString().trim();
Map<String, Object> parameters = new LinkedHashMap<>(); // Inicializamos vacío para evitar NPEs después
if (dfQueryResult.hasParameters()) {
// Usamos un forEach en lugar de Collectors.toMap para tener control total sobre nulos
dfQueryResult.getParameters().getFieldsMap().forEach((key, value) -> {
try {
Object convertedValue = ProtobufUtil.convertProtobufValueToJavaObject(value);
// Si el valor convertido es nulo, decidimos qué hacer.
// Lo mejor es poner un String vacío o ignorarlo para que no explote tu lógica.
if (convertedValue != null) {
parameters.put(key, convertedValue);
} else {
logger.warn("El parámetro '{}' devolvió un valor nulo al convertir. Se ignorará.", key);
// Opcional: parameters.put(key, "");
}
} catch (Exception e) {
logger.error("Error convirtiendo el parámetro '{}' de Protobuf a Java: {}", key, e.getMessage());
}
});
logger.debug("Extracted parameters: {} for session: {}", parameters, sessionId);
} else {
logger.debug("No parameters found in QueryResult for session: {}. Using empty map.", sessionId);
}
QueryResultDTO ourQueryResult = new QueryResultDTO(responseText, parameters);
logger.debug("Internal QueryResult DTO created for session: {}. Details: {}", sessionId, ourQueryResult);
DetectIntentResponseDTO finalResponse = new DetectIntentResponseDTO(responseId, ourQueryResult);
logger.info("Finished mapping DialogflowDetectIntentResponse for session: {}. Full response ID: {}", sessionId, responseId);
return finalResponse;
}
}

View File

@@ -1,85 +0,0 @@
/*
* 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.
*/
package com.example.mapper.conversation;
import com.example.dto.dialogflow.base.DetectIntentRequestDTO;
import com.example.dto.dialogflow.conversation.ExternalConvRequestDTO;
import com.example.dto.dialogflow.conversation.QueryInputDTO;
import com.example.dto.dialogflow.conversation.QueryParamsDTO;
import com.example.dto.dialogflow.conversation.TextInputDTO;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
/**
* Spring component responsible for mapping a simplified, external API request
* into a structured `DetectIntentRequestDTO` for Dialogflow. It processes
* user messages and relevant context, such as phone numbers and channel information,
* and populates the `QueryInputDTO` and `QueryParamsDTO` fields required for
* a Dialogflow API call.
*/
@Component
public class ExternalConvRequestMapper {
private static final Logger logger = LoggerFactory.getLogger(ExternalConvRequestMapper.class);
private static final String DEFAULT_LANGUAGE_CODE = "es";
public DetectIntentRequestDTO mapExternalRequestToDetectIntentRequest(ExternalConvRequestDTO externalRequest) {
Objects.requireNonNull(externalRequest, "ExternalRequestDTO cannot be null for mapping.");
if (externalRequest.message() == null || externalRequest.message().isBlank()) {
throw new IllegalArgumentException("External request 'mensaje' (message) is required.");
}
TextInputDTO textInput = new TextInputDTO(externalRequest.message());
QueryInputDTO queryInputDTO = new QueryInputDTO(textInput,null,DEFAULT_LANGUAGE_CODE);
// 2. Map ALL relevant external fields into QueryParamsDTO.parameters
Map<String, Object> parameters = new HashMap<>();
String primaryPhoneNumber = null;
if (externalRequest.user() != null && externalRequest.user().telefono() != null
&& !externalRequest.user().telefono().isBlank()) {
primaryPhoneNumber = externalRequest.user().telefono();
parameters.put("telefono", primaryPhoneNumber);
}
if (primaryPhoneNumber == null || primaryPhoneNumber.isBlank()) {
throw new IllegalArgumentException(
"Phone number is required in the 'usuario' field for conversation management.");
}
String resolvedUserId = null;
// Derive from phone number if not provided by 'userId' parameter
resolvedUserId = "user_by_phone_" + primaryPhoneNumber.replaceAll("[^0-9]", "");
parameters.put("usuario_id", resolvedUserId); // Ensure derived ID is also in params
logger.warn("User ID not provided in external request. Using derived ID from phone number: {}", resolvedUserId);
if (externalRequest.channel() != null && !externalRequest.channel().isBlank()) {
parameters.put("canal", externalRequest.channel());
logger.debug("Mapped 'canal' from external request: {}", externalRequest.channel());
}
if (externalRequest.user() != null && externalRequest.user().nickname() != null
&& !externalRequest.user().nickname().isBlank()) {
parameters.put("nickname", externalRequest.user().nickname());
logger.debug("Mapped 'nickname' from external request: {}", externalRequest.user().nickname());
}
if (externalRequest.tipo() != null) {
parameters.put("tipo", externalRequest.tipo());
logger.debug("Mapped 'tipo' from external request: {}", externalRequest.tipo());
}
QueryParamsDTO queryParamsDTO = new QueryParamsDTO(parameters);
// 3. Construct the final DetectIntentRequestDTO
return new DetectIntentRequestDTO(queryInputDTO, queryParamsDTO);
}
}

View File

@@ -1,53 +0,0 @@
/*
* 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.
*/
package com.example.mapper.conversation;
import com.example.dto.dialogflow.conversation.ConversationSessionDTO;
import com.google.cloud.Timestamp;
import com.google.cloud.firestore.DocumentSnapshot;
import org.springframework.stereotype.Component;
import java.time.Instant;
import java.util.HashMap;
import java.util.Map;
@Component
public class FirestoreConversationMapper {
public ConversationSessionDTO mapFirestoreDocumentToConversationSessionDTO(DocumentSnapshot document) {
if (document == null || !document.exists()) {
return null;
}
Timestamp createdAtTimestamp = document.getTimestamp("fechaCreacion");
Timestamp lastModifiedTimestamp = document.getTimestamp("ultimaActualizacion");
Instant createdAt = (createdAtTimestamp != null) ? createdAtTimestamp.toDate().toInstant() : null;
Instant lastModified = (lastModifiedTimestamp != null) ? lastModifiedTimestamp.toDate().toInstant() : null;
return new ConversationSessionDTO(
document.getString("sessionId"),
document.getString("userId"),
document.getString("telefono"),
createdAt,
lastModified,
document.getString("ultimoMensaje"),
document.getString("pantallaContexto")
);
}
public Map<String, Object> createSessionMap(ConversationSessionDTO session) {
Map<String, Object> sessionMap = new HashMap<>();
sessionMap.put("sessionId", session.sessionId());
sessionMap.put("userId", session.userId());
sessionMap.put("telefono", session.telefono());
sessionMap.put("fechaCreacion", session.createdAt());
sessionMap.put("ultimaActualizacion", session.lastModified());
sessionMap.put("ultimoMensaje", session.lastMessage());
sessionMap.put("pantallaContexto", session.pantallaContexto());
return sessionMap;
}
}

View File

@@ -1,119 +0,0 @@
package com.example.mapper.messagefilter;
import com.example.dto.dialogflow.conversation.ConversationMessageDTO;
import com.example.dto.dialogflow.conversation.ConversationSessionDTO;
import com.example.dto.dialogflow.conversation.MessageType;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Comparator;
import java.util.List;
import java.util.stream.Collectors;
@Component
public class ConversationContextMapper {
@Value("${conversation.context.message.limit:60}")
private int messageLimit;
@Value("${conversation.context.days.limit:30}")
private int daysLimit;
private static final int MAX_HISTORY_BYTES = 50 * 1024; // 50 KB
private static final String NOTIFICATION_TEXT_PARAM = "notification_text";
public String toText(ConversationSessionDTO session, List<ConversationMessageDTO> messages) {
if (messages == null || messages.isEmpty()) {
return "";
}
return toTextFromMessages(messages);
}
public String toTextWithLimits(ConversationSessionDTO session, List<ConversationMessageDTO> messages) {
if (messages == null || messages.isEmpty()) {
return "";
}
Instant thirtyDaysAgo = Instant.now().minus(daysLimit, ChronoUnit.DAYS);
List<ConversationMessageDTO> recentEntries = messages.stream()
.filter(entry -> entry.timestamp() != null && entry.timestamp().isAfter(thirtyDaysAgo))
.sorted(Comparator.comparing(ConversationMessageDTO::timestamp).reversed())
.limit(messageLimit)
.sorted(Comparator.comparing(ConversationMessageDTO::timestamp))
.collect(Collectors.toList());
return toTextWithTruncation(recentEntries);
}
public String toTextFromMessages(List<ConversationMessageDTO> messages) {
return messages.stream()
.map(this::formatEntry)
.collect(Collectors.joining("\n"));
}
public String toTextWithTruncation(List<ConversationMessageDTO> messages) {
if (messages == null || messages.isEmpty()) {
return "";
}
StringBuilder textBlock = new StringBuilder();
List<String> formattedMessages = messages.stream()
.map(this::formatEntry)
.collect(Collectors.toList());
for (int i = formattedMessages.size() - 1; i >= 0; i--) {
String message = formattedMessages.get(i) + "\n";
if (textBlock.toString().getBytes(java.nio.charset.StandardCharsets.UTF_8).length + message.getBytes(java.nio.charset.StandardCharsets.UTF_8).length > MAX_HISTORY_BYTES) {
break;
}
textBlock.insert(0, message);
}
return textBlock.toString().trim();
}
private String formatEntry(ConversationMessageDTO entry) {
String prefix = "User: ";
String content = entry.text();
if (entry.type() != null) {
switch (entry.type()) {
case AGENT:
prefix = "Agent: ";
break;
case SYSTEM:
prefix = "System: ";
// fix: add notification in the conversation.
if (entry.parameters() != null && entry.parameters().containsKey(NOTIFICATION_TEXT_PARAM)) {
Object paramText = entry.parameters().get(NOTIFICATION_TEXT_PARAM);
if (paramText != null && !paramText.toString().isBlank()) {
content = paramText.toString();
}
}
break;
case LLM:
prefix = "System: ";
break;
case USER:
default:
prefix = "User: ";
break;
}
}
String text = prefix + content;
if (entry.type() == MessageType.AGENT) {
text = cleanAgentMessage(text);
}
return text;
}
private String cleanAgentMessage(String message) {
return message.replaceAll("\\s*\\{.*\\}\\s*$", "").trim();
}
}

View File

@@ -1,33 +0,0 @@
/*
* 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.
*/
package com.example.mapper.messagefilter;
import com.example.dto.dialogflow.notification.NotificationDTO;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.stream.Collectors;
@Component
public class NotificationContextMapper {
public String toText(NotificationDTO notification) {
if (notification == null || notification.texto() == null) {
return "";
}
return notification.texto();
}
public String toText(List<NotificationDTO> notifications) {
if (notifications == null || notifications.isEmpty()) {
return "";
}
return notifications.stream()
.map(NotificationDTO::texto)
.filter(texto -> texto != null && !texto.isBlank())
.collect(Collectors.joining("\n"));
}
}

View File

@@ -1,68 +0,0 @@
/*
* 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.
*/
package com.example.mapper.notification;
import com.example.dto.dialogflow.notification.ExternalNotRequestDTO;
import com.example.dto.dialogflow.base.DetectIntentRequestDTO;
import com.example.dto.dialogflow.conversation.QueryInputDTO;
import com.example.dto.dialogflow.conversation.QueryParamsDTO;
import com.example.dto.dialogflow.conversation.TextInputDTO;
import org.springframework.stereotype.Component;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
/**
* Spring component for mapping an external notification request to a Dialogflow `DetectIntentRequestDTO`.
* This class takes a simplified `ExternalNotRequestDTO` and converts it into the structured
* DTO required for a Dialogflow API call, specifically for triggering a notification event.
* It ensures required parameters like the phone number are present and populates the
* request with event-specific details.
*/
@Component
public class ExternalNotRequestMapper {
private static final String LANGUAGE_CODE = "es";
private static final String TELEPHONE_PARAM_NAME = "telefono";
private static final String NOTIFICATION_TEXT_PARAM = "notification_text";
private static final String NOTIFICATION_LABEL = "NOTIFICACION";
private static final String PREFIX_PO_PARAM = "notification_po_";
public DetectIntentRequestDTO map(ExternalNotRequestDTO request) {
Objects.requireNonNull(request, "NotificationRequestDTO cannot be null for mapping.");
if (request.phoneNumber() == null || request.phoneNumber().isEmpty()) {
throw new IllegalArgumentException("Phone numbers is required and cannot be empty in NotificationRequestDTO.");
}
String phoneNumber = request.phoneNumber();
Map<String, Object> parameters = new HashMap<>();
parameters.put(TELEPHONE_PARAM_NAME, phoneNumber);
parameters.put(NOTIFICATION_TEXT_PARAM, request.text());
if (request.hiddenParameters() != null && !request.hiddenParameters().isEmpty()) {
StringBuilder poBuilder = new StringBuilder();
request.hiddenParameters().forEach((key, value) -> {
parameters.put(PREFIX_PO_PARAM + key, value);
poBuilder.append(key).append(": ").append(value).append("\n");
});
parameters.put("po", poBuilder.toString());
}
TextInputDTO textInput = new TextInputDTO(NOTIFICATION_LABEL);
QueryInputDTO queryInput = new QueryInputDTO(textInput, null, LANGUAGE_CODE);
QueryParamsDTO queryParams = new QueryParamsDTO(parameters);
return new DetectIntentRequestDTO(queryInput, queryParams);
}
}

View File

@@ -1,73 +0,0 @@
/*
* 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.
*/
package com.example.mapper.notification;
import com.example.dto.dialogflow.notification.NotificationDTO;
import org.springframework.stereotype.Component;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.cloud.firestore.DocumentSnapshot;
import java.time.Instant;
import java.util.Map;
import java.util.Objects;
/**
* Spring component for mapping notification data between application DTOs and Firestore documents.
* This class handles the transformation of Dialogflow event details and notification metadata
* into a `NotificationDTO` for persistence and provides methods to serialize and deserialize
* this DTO to and from Firestore-compatible data structures.
*/
@Component
public class FirestoreNotificationMapper {
private static final String DEFAULT_LANGUAGE_CODE = "es";
private static final String FIXED_EVENT_NAME = "notificacion";
private final ObjectMapper objectMapper;
private static final String DEFAULT_NOTIFICATION_STATUS="ACTIVE";
public FirestoreNotificationMapper(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
public NotificationDTO mapToFirestoreNotification(
String notificationId,
String telephone,
String notificationText,
Map<String, Object> parameters) {
Objects.requireNonNull(notificationId, "Notification ID cannot be null for mapping.");
Objects.requireNonNull(notificationText, "Notification text cannot be null for mapping.");
Objects.requireNonNull(parameters, "Dialogflow parameters map cannot be null.");
return new NotificationDTO(
notificationId,
telephone,
Instant.now(),
notificationText,
FIXED_EVENT_NAME,
DEFAULT_LANGUAGE_CODE,
parameters,
DEFAULT_NOTIFICATION_STATUS
);
}
public NotificationDTO mapFirestoreDocumentToNotificationDTO(DocumentSnapshot documentSnapshot) {
Objects.requireNonNull(documentSnapshot, "DocumentSnapshot cannot be null for mapping.");
if (!documentSnapshot.exists()) {
throw new IllegalArgumentException("DocumentSnapshot does not exist.");
}
try {
return objectMapper.convertValue(documentSnapshot.getData(), NotificationDTO.class);
} catch (IllegalArgumentException e) {
throw new IllegalArgumentException(
"Failed to convert Firestore document data to NotificationDTO for ID " + documentSnapshot.getId(), e);
}
}
public Map<String, Object> mapNotificationDTOToMap(NotificationDTO notificationDTO) {
Objects.requireNonNull(notificationDTO, "NotificationDTO cannot be null for mapping to map.");
return objectMapper.convertValue(notificationDTO, new com.fasterxml.jackson.core.type.TypeReference<Map<String, Object>>() {});
}
}

View File

@@ -1,229 +0,0 @@
/*
* 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.
*/
package com.example.repository;
import com.example.util.FirestoreTimestampDeserializer;
import com.example.util.FirestoreTimestampSerializer;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.databind.module.SimpleModule;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import com.fasterxml.jackson.module.paramnames.ParameterNamesModule;
import com.google.api.core.ApiFuture;
import com.google.cloud.firestore.DocumentReference;
import com.google.cloud.firestore.DocumentSnapshot;
import com.google.cloud.firestore.Firestore;
import com.google.cloud.firestore.Query;
import com.google.cloud.firestore.QueryDocumentSnapshot;
import com.google.cloud.firestore.QuerySnapshot;
import com.google.cloud.firestore.WriteBatch;
import com.google.cloud.firestore.WriteResult;
import com.google.cloud.firestore.CollectionReference;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.ExecutionException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Repository;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
/**
* A base repository for performing low-level operations with Firestore. It provides a generic
* interface for common data access tasks such as getting document references, performing reads,
* writes, and batched updates. This class also handles the serialization and deserialization of
* Java objects to and from Firestore documents using an `ObjectMapper`.
*/
@Repository
public class FirestoreBaseRepository {
private static final Logger logger = LoggerFactory.getLogger(FirestoreBaseRepository.class);
private final Firestore firestore;
private final ObjectMapper objectMapper;
@Value("${app.id:default-app-id}")
private String appId;
public FirestoreBaseRepository(Firestore firestore, ObjectMapper objectMapper) {
this.firestore = firestore;
this.objectMapper = objectMapper;
// Register JavaTimeModule for standard java.time handling
if (!ObjectMapper.findModules().stream().anyMatch(m -> m instanceof JavaTimeModule)) {
objectMapper.registerModule(new JavaTimeModule());
}
objectMapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false);
// Register ParameterNamesModule, crucial for Java Records and classes compiled with -parameters
if (!ObjectMapper.findModules().stream().anyMatch(m -> m instanceof ParameterNamesModule)) {
objectMapper.registerModule(new ParameterNamesModule());
}
// These specific Timestamp (Google Cloud) deserializers/serializers are for ObjectMapper
// to handle com.google.cloud.Timestamp objects when mapping other types.
// They are generally not the cause of the Redis deserialization error for Instant.
SimpleModule firestoreTimestampModule = new SimpleModule();
firestoreTimestampModule.addDeserializer(
com.google.cloud.Timestamp.class, new FirestoreTimestampDeserializer());
firestoreTimestampModule.addSerializer(
com.google.cloud.Timestamp.class, new FirestoreTimestampSerializer());
objectMapper.registerModule(firestoreTimestampModule);
logger.info(
"FirestoreBaseRepository initialized with Firestore client and ObjectMapper. App ID will be: {}",
appId);
}
public DocumentReference getDocumentReference(String collectionPath, String documentId) {
Objects.requireNonNull(collectionPath, "Collection path cannot be null.");
Objects.requireNonNull(documentId, "Document ID cannot be null.");
return firestore.collection(collectionPath).document(documentId);
}
public <T> T getDocument(DocumentReference docRef, Class<T> clazz)
throws InterruptedException, ExecutionException {
Objects.requireNonNull(docRef, "DocumentReference cannot be null.");
Objects.requireNonNull(clazz, "Class for mapping cannot be null.");
ApiFuture<DocumentSnapshot> future = docRef.get();
DocumentSnapshot document = future.get();
if (document.exists()) {
try {
logger.debug(
"FirestoreBaseRepository: Raw document data for {}: {}",
docRef.getPath(),
document.getData());
T result = objectMapper.convertValue(document.getData(), clazz);
return result;
} catch (IllegalArgumentException e) {
logger.error(
"Failed to convert Firestore document data to {}: {}", clazz.getName(), e.getMessage(), e);
throw new RuntimeException(
"Failed to convert Firestore document data to " + clazz.getName(), e);
}
}
return null;
}
public DocumentSnapshot getDocumentSnapshot(DocumentReference docRef)
throws ExecutionException, InterruptedException {
Objects.requireNonNull(docRef, "DocumentReference cannot be null.");
ApiFuture<DocumentSnapshot> future = docRef.get();
return future.get();
}
public Flux<DocumentSnapshot> getDocuments(String collectionPath) {
return Flux.create(sink -> {
ApiFuture<QuerySnapshot> future = firestore.collection(collectionPath).get();
future.addListener(() -> {
try {
QuerySnapshot querySnapshot = future.get();
if (querySnapshot != null) {
querySnapshot.getDocuments().forEach(sink::next);
}
sink.complete();
} catch (InterruptedException | ExecutionException e) {
sink.error(e);
}
}, Runnable::run);
});
}
public Mono<DocumentSnapshot> getDocumentsByField(
String collectionPath, String fieldName, String value) {
return Mono.fromCallable(
() -> {
Query query = firestore.collection(collectionPath).whereEqualTo(fieldName, value);
ApiFuture<QuerySnapshot> future = query.get();
QuerySnapshot querySnapshot = future.get();
if (!querySnapshot.isEmpty()) {
return querySnapshot.getDocuments().get(0);
}
return null;
});
}
public boolean documentExists(DocumentReference docRef)
throws InterruptedException, ExecutionException {
Objects.requireNonNull(docRef, "DocumentReference cannot be null.");
ApiFuture<DocumentSnapshot> future = docRef.get();
return future.get().exists();
}
public void setDocument(DocumentReference docRef, Object data)
throws InterruptedException, ExecutionException {
Objects.requireNonNull(docRef, "DocumentReference cannot be null.");
Objects.requireNonNull(data, "Data for setting document cannot be null.");
ApiFuture<WriteResult> future = docRef.set(data);
WriteResult writeResult = future.get();
logger.debug(
"Document set: {} with update time: {}", docRef.getPath(), writeResult.getUpdateTime());
}
public void updateDocument(DocumentReference docRef, Map<String, Object> updates)
throws InterruptedException, ExecutionException {
Objects.requireNonNull(docRef, "DocumentReference cannot be null.");
Objects.requireNonNull(updates, "Updates map cannot be null.");
ApiFuture<WriteResult> future = docRef.update(updates);
WriteResult writeResult = future.get();
logger.debug(
"Document updated: {} with update time: {}", docRef.getPath(), writeResult.getUpdateTime());
}
public void deleteDocument(DocumentReference docRef)
throws InterruptedException, ExecutionException {
Objects.requireNonNull(docRef, "DocumentReference cannot be null.");
ApiFuture<WriteResult> future = docRef.delete();
WriteResult writeResult = future.get();
logger.debug(
"Document deleted: {} with update time: {}", docRef.getPath(), writeResult.getUpdateTime());
}
public WriteBatch createBatch() {
return firestore.batch();
}
public void commitBatch(WriteBatch batch) throws InterruptedException, ExecutionException {
Objects.requireNonNull(batch, "WriteBatch cannot be null.");
batch.commit().get();
logger.debug("Batch committed successfully.");
}
public String getAppId() {
return appId;
}
public void deleteCollection(String collectionPath, int batchSize) {
try {
CollectionReference collection = firestore.collection(collectionPath);
ApiFuture<QuerySnapshot> future = collection.limit(batchSize).get();
int deleted = 0;
// future.get() blocks on document retrieval
List<QueryDocumentSnapshot> documents = future.get().getDocuments();
while (!documents.isEmpty()) {
for (QueryDocumentSnapshot document : documents) {
document.getReference().delete();
++deleted;
}
future = collection.limit(batchSize).get();
documents = future.get().getDocuments();
}
logger.info("Deleted {} documents from collection {}", deleted, collectionPath);
} catch (Exception e) {
logger.error("Error deleting collection: " + e.getMessage(), e);
throw new RuntimeException("Error deleting collection", e);
}
}
public void deleteDocumentAndSubcollections(DocumentReference docRef, String subcollection)
throws ExecutionException, InterruptedException {
deleteCollection(docRef.collection(subcollection).getPath(), 50);
deleteDocument(docRef);
}
}

View File

@@ -1,214 +0,0 @@
package com.example.service.base;
import com.example.repository.FirestoreBaseRepository;
import com.google.cloud.firestore.CollectionReference;
import com.google.cloud.firestore.Firestore;
import com.google.cloud.firestore.QueryDocumentSnapshot;
import java.util.List;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.data.redis.core.ReactiveRedisTemplate;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;
@Service
public class DataPurgeService {
private static final Logger logger = LoggerFactory.getLogger(DataPurgeService.class);
private final ReactiveRedisTemplate<String, ?> redisTemplate;
private final FirestoreBaseRepository firestoreBaseRepository;
private final Firestore firestore;
@Autowired
public DataPurgeService(
@Qualifier("reactiveRedisTemplate") ReactiveRedisTemplate<String, ?> redisTemplate,
FirestoreBaseRepository firestoreBaseRepository, Firestore firestore) {
this.redisTemplate = redisTemplate;
this.firestoreBaseRepository = firestoreBaseRepository;
this.firestore = firestore;
}
public Mono<Void> purgeAllData() {
return purgeRedis()
.then(purgeFirestore());
}
private Mono<Void> purgeRedis() {
logger.info("Starting Redis data purge.");
return redisTemplate.getConnectionFactory().getReactiveConnection().serverCommands().flushAll()
.doOnSuccess(v -> logger.info("Successfully purged all data from Redis."))
.doOnError(e -> logger.error("Error purging data from Redis.", e))
.then();
}
private Mono<Void> purgeFirestore() {
logger.info("Starting Firestore data purge.");
return Mono.fromRunnable(() -> {
try {
String appId = firestoreBaseRepository.getAppId();
String conversationsCollectionPath = String.format("artifacts/%s/conversations", appId);
String notificationsCollectionPath = String.format("artifacts/%s/notifications", appId);
// Delete 'mensajes' sub-collections in 'conversations'
logger.info("Deleting 'mensajes' sub-collections from '{}'", conversationsCollectionPath);
try {
List<QueryDocumentSnapshot> conversationDocuments = firestore.collection(conversationsCollectionPath).get().get().getDocuments();
for (QueryDocumentSnapshot document : conversationDocuments) {
String messagesCollectionPath = document.getReference().getPath() + "/mensajes";
logger.info("Deleting sub-collection: {}", messagesCollectionPath);
firestoreBaseRepository.deleteCollection(messagesCollectionPath, 50);
}
} catch (Exception e) {
if (e.getMessage().contains("NOT_FOUND")) {
logger.warn("Collection '{}' not found, skipping.", conversationsCollectionPath);
} else {
throw e;
}
}
// Delete the 'conversations' collection
logger.info("Deleting collection: {}", conversationsCollectionPath);
try {
firestoreBaseRepository.deleteCollection(conversationsCollectionPath, 50);
} catch (Exception e) {
if (e.getMessage().contains("NOT_FOUND")) {
logger.warn("Collection '{}' not found, skipping.", conversationsCollectionPath);
}
else {
throw e;
}
}
// Delete the 'notifications' collection
logger.info("Deleting collection: {}", notificationsCollectionPath);
try {
firestoreBaseRepository.deleteCollection(notificationsCollectionPath, 50);
} catch (Exception e) {
if (e.getMessage().contains("NOT_FOUND")) {
logger.warn("Collection '{}' not found, skipping.", notificationsCollectionPath);
} else {
throw e;
}
}
logger.info("Successfully purged Firestore collections.");
} catch (Exception e) {
logger.error("Error purging Firestore collections.", e);
throw new RuntimeException("Failed to purge Firestore collections.", e);
}
}).subscribeOn(Schedulers.boundedElastic()).then();
}
}

View File

@@ -1,167 +0,0 @@
/*
* 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.
*/
package com.example.service.base;
import com.example.mapper.conversation.DialogflowRequestMapper;
import com.example.mapper.conversation.DialogflowResponseMapper;
import com.example.dto.dialogflow.base.DetectIntentRequestDTO;
import com.example.dto.dialogflow.base.DetectIntentResponseDTO;
import com.example.exception.DialogflowClientException;
import com.google.api.gax.rpc.ApiException;
import com.google.cloud.dialogflow.cx.v3.DetectIntentRequest;
import com.google.cloud.dialogflow.cx.v3.QueryParameters;
import com.google.cloud.dialogflow.cx.v3.SessionsClient;
import com.google.cloud.dialogflow.cx.v3.SessionName;
import com.google.cloud.dialogflow.cx.v3.SessionsSettings;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Mono;
import javax.annotation.PreDestroy;
import java.io.IOException;
import java.util.Objects;
import reactor.util.retry.Retry;
/**
* Service for interacting with the Dialogflow CX API to detect user DetectIntent.
* It encapsulates the low-level API calls, handling request mapping from DTOs,
* managing the `SessionsClient`, and translating API responses into DTOs,
* all within a reactive programming context.
*/
@Service
public class DialogflowClientService {
private static final Logger logger = LoggerFactory.getLogger(DialogflowClientService.class);
private final String dialogflowCxProjectId;
private final String dialogflowCxLocation;
private final String dialogflowCxAgentId;
private final DialogflowRequestMapper dialogflowRequestMapper;
private final DialogflowResponseMapper dialogflowResponseMapper;
private SessionsClient sessionsClient;
public DialogflowClientService(
@org.springframework.beans.factory.annotation.Value("${dialogflow.cx.project-id}") String dialogflowCxProjectId,
@org.springframework.beans.factory.annotation.Value("${dialogflow.cx.location}") String dialogflowCxLocation,
@org.springframework.beans.factory.annotation.Value("${dialogflow.cx.agent-id}") String dialogflowCxAgentId,
DialogflowRequestMapper dialogflowRequestMapper,
DialogflowResponseMapper dialogflowResponseMapper)
throws IOException {
this.dialogflowCxProjectId = dialogflowCxProjectId;
this.dialogflowCxLocation = dialogflowCxLocation;
this.dialogflowCxAgentId = dialogflowCxAgentId;
this.dialogflowRequestMapper = dialogflowRequestMapper;
this.dialogflowResponseMapper = dialogflowResponseMapper;
try {
String regionalEndpoint = String.format("%s-dialogflow.googleapis.com:443", dialogflowCxLocation);
SessionsSettings sessionsSettings = SessionsSettings.newBuilder()
.setEndpoint(regionalEndpoint)
.build();
this.sessionsClient = SessionsClient.create(sessionsSettings);
logger.info("Dialogflow CX SessionsClient initialized successfully for endpoint: {}", regionalEndpoint);
logger.info("Dialogflow CX SessionsClient initialized successfully for agent - Test Agent version: {}", dialogflowCxAgentId);
} catch (IOException e) {
logger.error("Failed to create Dialogflow CX SessionsClient: {}", e.getMessage(), e);
throw e;
}
}
@PreDestroy
public void closeSessionsClient() {
if (sessionsClient != null) {
sessionsClient.close();
logger.info("Dialogflow CX SessionsClient closed.");
}
}
public Mono<DetectIntentResponseDTO> detectIntent(
String sessionId,
DetectIntentRequestDTO request) {
Objects.requireNonNull(sessionId, "Dialogflow session ID cannot be null.");
Objects.requireNonNull(request, "Dialogflow request DTO cannot be null.");
logger.info("Initiating detectIntent for session: {}", sessionId);
DetectIntentRequest.Builder detectIntentRequestBuilder;
try {
detectIntentRequestBuilder = dialogflowRequestMapper.mapToDetectIntentRequestBuilder(request);
logger.debug("Obtained partial DetectIntentRequest.Builder from mapper for session: {}", sessionId);
} catch (IllegalArgumentException e) {
logger.error(" Failed to map DTO to partial Protobuf request for session {}: {}", sessionId, e.getMessage());
return Mono.error(new IllegalArgumentException("Invalid Dialogflow request input: " + e.getMessage()));
}
SessionName sessionName = SessionName.newBuilder()
.setProject(dialogflowCxProjectId)
.setLocation(dialogflowCxLocation)
.setAgent(dialogflowCxAgentId)
.setSession(sessionId)
.build();
detectIntentRequestBuilder.setSession(sessionName.toString());
logger.debug("Set session path {} on the request builder for session: {}", sessionName.toString(), sessionId);
QueryParameters.Builder queryParamsBuilder;
if (detectIntentRequestBuilder.hasQueryParams()) {
queryParamsBuilder = detectIntentRequestBuilder.getQueryParams().toBuilder();
} else {
queryParamsBuilder = QueryParameters.newBuilder();
}
detectIntentRequestBuilder.setQueryParams(queryParamsBuilder.build());
// Build the final DetectIntentRequest Protobuf object
DetectIntentRequest detectIntentRequest = detectIntentRequestBuilder.build();
return Mono.fromCallable(() -> {
logger.debug("Calling Dialogflow CX detectIntent for session: {}", sessionId);
return sessionsClient.detectIntent(detectIntentRequest);
})
.retryWhen(reactor.util.retry.Retry.backoff(3, java.time.Duration.ofSeconds(1))
.filter(throwable -> {
if (throwable instanceof ApiException apiException) {
com.google.api.gax.rpc.StatusCode.Code code = apiException.getStatusCode().getCode();
boolean isRetryable = code == com.google.api.gax.rpc.StatusCode.Code.INTERNAL ||
code == com.google.api.gax.rpc.StatusCode.Code.UNAVAILABLE;
if (isRetryable) {
logger.warn("Retrying Dialogflow CX call for session {} due to transient error: {}", sessionId, code);
}
return isRetryable;
}
return false;
})
.doBeforeRetry(retrySignal -> logger.debug("Retry attempt #{} for session {}",
retrySignal.totalRetries() + 1, sessionId))
.onRetryExhaustedThrow((retrySpec, retrySignal) -> {
logger.error("Dialogflow CX retries exhausted for session {}", sessionId);
return retrySignal.failure();
})
)
.onErrorMap(ApiException.class, e -> {
String statusCode = e.getStatusCode().getCode().name();
String message = e.getMessage();
String detailedLog = message;
if (e.getCause() instanceof io.grpc.StatusRuntimeException grpcEx) {
detailedLog = String.format("Status: %s, Message: %s, Trailers: %s",
grpcEx.getStatus().getCode(),
grpcEx.getStatus().getDescription(),
grpcEx.getTrailers());
}
logger.error("Dialogflow CX API error for session {}: details={}",
sessionId, detailedLog, e);
return new DialogflowClientException(
"Dialogflow CX API error: " + statusCode + " - " + message, e);
})
.map(dfResponse -> this.dialogflowResponseMapper.mapFromDialogflowResponse(dfResponse, sessionId));
}
}

View File

@@ -1,60 +0,0 @@
/*
* 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.
*/
package com.example.service.base;
import com.example.exception.GeminiClientException;
import com.google.genai.Client;
import com.google.genai.errors.GenAiIOException;
import com.google.genai.types.Content;
import com.google.genai.types.GenerateContentConfig;
import com.google.genai.types.GenerateContentResponse;
import com.google.genai.types.Part;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
/**
* Service for interacting with the Gemini API to generate content.
* It encapsulates the low-level API calls, handling prompt configuration,
* and error management to provide a clean and robust content generation interface.
*/
@Service
public class GeminiClientService {
private static final Logger logger = LoggerFactory.getLogger(GeminiClientService.class);
private final Client geminiClient;
public GeminiClientService(Client geminiClient) {
this.geminiClient = geminiClient;
}
public String generateContent(String prompt, Float temperature, Integer maxOutputTokens, String modelName,Float topP) throws GeminiClientException {
try {
Content content = Content.fromParts(Part.fromText(prompt));
GenerateContentConfig config = GenerateContentConfig.builder()
.temperature(temperature)
.maxOutputTokens(maxOutputTokens)
.topP(topP)
.build();
logger.debug("Sending request to Gemini model '{}'", modelName);
GenerateContentResponse response = geminiClient.models.generateContent(modelName, content, config);
if (response != null && response.text() != null) {
return response.text();
} else {
logger.warn("Gemini returned no content or an unexpected response structure for model '{}'.", modelName);
throw new GeminiClientException("No content generated or unexpected response structure.");
}
} catch (GenAiIOException e) {
logger.error("Gemini API I/O error while calling model '{}': {}", modelName, e.getMessage(), e);
throw new GeminiClientException("An API communication issue occurred: " + e.getMessage(), e);
} catch (Exception e) {
logger.error("An unexpected error occurred during Gemini content generation for model '{}': {}", modelName, e.getMessage(), e);
throw new GeminiClientException("An unexpected issue occurred during content generation.", e);
}
}
}

View File

@@ -1,128 +0,0 @@
/*
* 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.
*/
package com.example.service.base;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import jakarta.annotation.PostConstruct;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.Objects;
import java.io.InputStream;
/**
* Classifies a user's text input into a predefined category using a Gemini
* model.
* It analyzes the user's query in the context of a conversation history and any
* relevant notifications to determine if the message is part of the ongoing
* dialogue
* or an interruption. The classification is used to route the request to the
* appropriate handler (e.g., a standard conversational flow or a specific
* notification processor).
*/
@Service
public class MessageEntryFilter {
private static final Logger logger = LoggerFactory.getLogger(MessageEntryFilter.class);
private final GeminiClientService geminiService;
@Value("${messagefilter.geminimodel:gemini-2.0-flash-001}")
private String geminiModelNameClassifier;
@Value("${messagefilter.temperature:0.1f}")
private Float classifierTemperature;
@Value("${messagefilter.maxOutputTokens:10}")
private Integer classifierMaxOutputTokens;
@Value("${messagefilter.topP:0.1f}")
private Float classifierTopP;
@Value("${messagefilter.prompt:prompts/message_filter_prompt.txt}")
private String promptFilePath;
public static final String CATEGORY_CONVERSATION = "CONVERSATION";
public static final String CATEGORY_NOTIFICATION = "NOTIFICATION";
public static final String CATEGORY_UNKNOWN = "UNKNOWN";
public static final String CATEGORY_ERROR = "ERROR";
private String promptTemplate;
public MessageEntryFilter(GeminiClientService geminiService) {
this.geminiService = Objects.requireNonNull(geminiService,
"GeminiClientService cannot be null for MessageEntryFilter.");
}
@PostConstruct
public void loadPromptTemplate() {
try (InputStream inputStream = getClass().getClassLoader().getResourceAsStream(promptFilePath)) {
if (inputStream == null) {
throw new IOException("Resource not found: " + promptFilePath);
}
byte[] fileBytes = inputStream.readAllBytes();
this.promptTemplate = new String(fileBytes, StandardCharsets.UTF_8);
logger.info("Successfully loaded prompt template from '" + promptFilePath + "'.");
} catch (IOException e) {
logger.error("Failed to load prompt template from '" + promptFilePath
+ "'. Please ensure the file exists. Error: " + e.getMessage());
throw new IllegalStateException("Could not load prompt template.", e);
}
}
public String classifyMessage(String queryInputText, String notificationsJson, String conversationJson) {
if (queryInputText == null || queryInputText.isBlank()) {
logger.warn("Query input text for classification is null or blank. Returning {}.", CATEGORY_UNKNOWN);
return CATEGORY_UNKNOWN;
}
String interruptingNotification = (notificationsJson != null && !notificationsJson.isBlank()) ?
notificationsJson : "No interrupting notification.";
String conversationHistory = (conversationJson != null && !conversationJson.isBlank()) ?
conversationJson : "No conversation history.";
String classificationPrompt = String.format(
this.promptTemplate,
conversationHistory,
interruptingNotification,
queryInputText
);
logger.debug("Sending classification request to Gemini for input (first 100 chars): '{}'...",
queryInputText.substring(0, Math.min(queryInputText.length(), 100)));
try {
String geminiResponse = geminiService.generateContent(
classificationPrompt,
classifierTemperature,
classifierMaxOutputTokens,
geminiModelNameClassifier,
classifierTopP
);
String resultCategory = switch (geminiResponse != null ? geminiResponse.strip().toUpperCase() : "") {
case CATEGORY_CONVERSATION -> {
logger.info("Classified as {}.", CATEGORY_CONVERSATION);
yield CATEGORY_CONVERSATION;
}
case CATEGORY_NOTIFICATION -> {
logger.info("Classified as {}.", CATEGORY_NOTIFICATION);
yield CATEGORY_NOTIFICATION;
}
default -> {
logger.warn("Gemini returned an unrecognised classification or was null/blank: '{}'. Expected '{}' or '{}'. Returning {}.",
geminiResponse, CATEGORY_CONVERSATION, CATEGORY_NOTIFICATION, CATEGORY_UNKNOWN);
yield CATEGORY_UNKNOWN;
}
};
return resultCategory;
} catch (Exception e) {
logger.error("An error occurred during Gemini content generation for message classification.", e);
return CATEGORY_UNKNOWN;
}
}
}

Some files were not shown because too many files have changed in this diff Show More