This commit is contained in:
2026-02-20 04:38:32 +00:00
parent 14ed21a1f9
commit bcdc41ecd5
20 changed files with 309 additions and 283 deletions

View File

@@ -38,3 +38,7 @@ dev = [
"ruff>=0.15.1", "ruff>=0.15.1",
"ty>=0.0.17", "ty>=0.0.17",
] ]
[tool.ruff.lint]
select = ['ALL']
ignore = []

View File

@@ -1,11 +1,10 @@
""" """Copyright 2025 Google. This software is provided as-is,
Copyright 2025 Google. This software is provided as-is,
without warranty or representation for any use or purpose. without warranty or representation for any use or purpose.
Your use of it is subject to your agreement with Google. Your use of it is subject to your agreement with Google.
Capa de Integración - Conversational AI Orchestrator Service Capa de Integración - Conversational AI Orchestrator Service
""" """
from .main import main, app from .main import app, main
__all__ = ["main", "app"] __all__ = ["app", "main"]

View File

@@ -1,4 +1,5 @@
from pathlib import Path from pathlib import Path
from pydantic import Field from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, SettingsConfigDict
@@ -18,11 +19,11 @@ class Settings(BaseSettings):
# Firestore # Firestore
firestore_database_id: str = Field(..., alias="GCP_FIRESTORE_DATABASE_ID") firestore_database_id: str = Field(..., alias="GCP_FIRESTORE_DATABASE_ID")
firestore_host: str = Field( firestore_host: str = Field(
default="firestore.googleapis.com", alias="GCP_FIRESTORE_HOST" default="firestore.googleapis.com", alias="GCP_FIRESTORE_HOST",
) )
firestore_port: int = Field(default=443, alias="GCP_FIRESTORE_PORT") firestore_port: int = Field(default=443, alias="GCP_FIRESTORE_PORT")
firestore_importer_enabled: bool = Field( firestore_importer_enabled: bool = Field(
default=False, alias="GCP_FIRESTORE_IMPORTER_ENABLE" default=False, alias="GCP_FIRESTORE_IMPORTER_ENABLE",
) )
# Redis # Redis
@@ -35,10 +36,10 @@ class Settings(BaseSettings):
# Conversation Context # Conversation Context
conversation_context_message_limit: int = Field( conversation_context_message_limit: int = Field(
default=60, alias="CONVERSATION_CONTEXT_MESSAGE_LIMIT" default=60, alias="CONVERSATION_CONTEXT_MESSAGE_LIMIT",
) )
conversation_context_days_limit: int = Field( conversation_context_days_limit: int = Field(
default=30, alias="CONVERSATION_CONTEXT_DAYS_LIMIT" default=30, alias="CONVERSATION_CONTEXT_DAYS_LIMIT",
) )
# Logging # Logging

View File

@@ -1,14 +1,15 @@
from functools import lru_cache from functools import lru_cache
from .config import settings from .config import settings
from .services import ( from .services import (
ConversationManagerService, ConversationManagerService,
DLPService,
NotificationManagerService, NotificationManagerService,
QuickReplyContentService, QuickReplyContentService,
DLPService,
) )
from .services.redis_service import RedisService
from .services.firestore_service import FirestoreService from .services.firestore_service import FirestoreService
from .services.rag_service import RAGService from .services.rag_service import RAGService
from .services.redis_service import RedisService
@lru_cache(maxsize=1) @lru_cache(maxsize=1)
@@ -70,7 +71,6 @@ def get_conversation_manager() -> ConversationManagerService:
def init_services(settings) -> None: def init_services(settings) -> None:
"""Initialize services (placeholder for compatibility).""" """Initialize services (placeholder for compatibility)."""
# Services are lazy-loaded via lru_cache, no explicit init needed # Services are lazy-loaded via lru_cache, no explicit init needed
pass
async def startup_services() -> None: async def startup_services() -> None:

View File

@@ -1,17 +1,16 @@
class FirestorePersistenceException(Exception): class FirestorePersistenceException(Exception):
""" """Exception raised when Firestore operations fail.
Exception raised when Firestore operations fail.
This is typically caught and logged without failing the request. This is typically caught and logged without failing the request.
""" """
def __init__(self, message: str, cause: Exception | None = None): def __init__(self, message: str, cause: Exception | None = None) -> None:
""" """Initialize Firestore persistence exception.
Initialize Firestore persistence exception.
Args: Args:
message: Error message message: Error message
cause: Original exception that caused this error cause: Original exception that caused this error
""" """
super().__init__(message) super().__init__(message)
self.cause = cause self.cause = cause

View File

@@ -5,9 +5,8 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from .config import settings from .config import settings
from .dependencies import init_services, shutdown_services, startup_services
from .routers import conversation_router, notification_router, quick_replies_router from .routers import conversation_router, notification_router, quick_replies_router
from .dependencies import init_services, startup_services, shutdown_services
# Configure logging # Configure logging
logging.basicConfig( logging.basicConfig(
@@ -64,7 +63,7 @@ async def health_check():
return {"status": "healthy", "service": "capa-de-integracion"} return {"status": "healthy", "service": "capa-de-integracion"}
def main(): def main() -> None:
"""Entry point for CLI.""" """Entry point for CLI."""
import uvicorn import uvicorn

View File

@@ -1,29 +1,29 @@
"""Data models module.""" """Data models module."""
from .conversation import ( from .conversation import (
User,
ConversationSession,
ConversationEntry, ConversationEntry,
ConversationRequest, ConversationRequest,
ConversationSession,
DetectIntentResponse, DetectIntentResponse,
QueryResult, QueryResult,
User,
) )
from .notification import ( from .notification import (
ExternalNotificationRequest, ExternalNotificationRequest,
NotificationSession,
Notification, Notification,
NotificationSession,
) )
__all__ = [ __all__ = [
# Conversation
"User",
"ConversationSession",
"ConversationEntry", "ConversationEntry",
"ConversationRequest", "ConversationRequest",
"ConversationSession",
"DetectIntentResponse", "DetectIntentResponse",
"QueryResult",
# Notification # Notification
"ExternalNotificationRequest", "ExternalNotificationRequest",
"NotificationSession",
"Notification", "Notification",
"NotificationSession",
"QueryResult",
# Conversation
"User",
] ]

View File

@@ -1,5 +1,6 @@
from datetime import datetime from datetime import datetime
from typing import Any, Literal from typing import Any, Literal
from pydantic import BaseModel, Field from pydantic import BaseModel, Field

View File

@@ -1,17 +1,17 @@
from datetime import datetime from datetime import datetime
from typing import Any from typing import Any
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
class Notification(BaseModel): class Notification(BaseModel):
""" """Individual notification event record.
Individual notification event record.
Represents a notification to be stored in Firestore and cached in Redis. Represents a notification to be stored in Firestore and cached in Redis.
""" """
idNotificacion: str = Field( idNotificacion: str = Field(
..., alias="idNotificacion", description="Unique notification ID" ..., alias="idNotificacion", description="Unique notification ID",
) )
telefono: str = Field(..., alias="telefono", description="User phone number") telefono: str = Field(..., alias="telefono", description="User phone number")
timestampCreacion: datetime = Field( timestampCreacion: datetime = Field(
@@ -36,7 +36,7 @@ class Notification(BaseModel):
description="Session parameters for Dialogflow", description="Session parameters for Dialogflow",
) )
status: str = Field( status: str = Field(
default="active", alias="status", description="Notification status" default="active", alias="status", description="Notification status",
) )
model_config = {"populate_by_name": True} model_config = {"populate_by_name": True}
@@ -52,8 +52,7 @@ class Notification(BaseModel):
parametros: dict[str, Any] | None = None, parametros: dict[str, Any] | None = None,
status: str = "active", status: str = "active",
) -> "Notification": ) -> "Notification":
""" """Create a new Notification with auto-filled timestamp.
Create a new Notification with auto-filled timestamp.
Args: Args:
id_notificacion: Unique notification ID id_notificacion: Unique notification ID
@@ -66,6 +65,7 @@ class Notification(BaseModel):
Returns: Returns:
New Notification instance with current timestamp New Notification instance with current timestamp
""" """
return cls( return cls(
idNotificacion=id_notificacion, idNotificacion=id_notificacion,
@@ -109,7 +109,7 @@ class ExternalNotificationRequest(BaseModel):
texto: str = Field(..., min_length=1) texto: str = Field(..., min_length=1)
telefono: str = Field(..., alias="telefono", description="User phone number") telefono: str = Field(..., alias="telefono", description="User phone number")
parametros_ocultos: dict[str, Any] | None = Field( parametros_ocultos: dict[str, Any] | None = Field(
None, alias="parametrosOcultos", description="Hidden parameters (metadata)" None, alias="parametrosOcultos", description="Hidden parameters (metadata)",
) )
model_config = {"populate_by_name": True} model_config = {"populate_by_name": True}

View File

@@ -1,31 +1,32 @@
import logging import logging
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from ..models import ConversationRequest, DetectIntentResponse from capa_de_integracion.dependencies import get_conversation_manager
from ..services import ConversationManagerService from capa_de_integracion.models import ConversationRequest, DetectIntentResponse
from ..dependencies import get_conversation_manager from capa_de_integracion.services import ConversationManagerService
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1/dialogflow", tags=["conversation"]) router = APIRouter(prefix="/api/v1/dialogflow", tags=["conversation"])
@router.post("/detect-intent", response_model=DetectIntentResponse) @router.post("/detect-intent")
async def detect_intent( async def detect_intent(
request: ConversationRequest, request: ConversationRequest,
conversation_manager: ConversationManagerService = Depends( conversation_manager: Annotated[ConversationManagerService, Depends(
get_conversation_manager get_conversation_manager,
), )],
) -> DetectIntentResponse: ) -> DetectIntentResponse:
""" """Detect user intent and manage conversation.
Detect user intent and manage conversation.
Args: Args:
request: External conversation request from client request: External conversation request from client
Returns: Returns:
Dialogflow detect intent response Dialogflow detect intent response
""" """
try: try:
logger.info("Received detect-intent request") logger.info("Received detect-intent request")
@@ -34,9 +35,9 @@ async def detect_intent(
return response return response
except ValueError as e: except ValueError as e:
logger.error(f"Validation error: {str(e)}", exc_info=True) logger.error(f"Validation error: {e!s}", exc_info=True)
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
except Exception as e: except Exception as e:
logger.error(f"Error processing detect-intent: {str(e)}", exc_info=True) logger.error(f"Error processing detect-intent: {e!s}", exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error") raise HTTPException(status_code=500, detail="Internal server error")

View File

@@ -1,10 +1,11 @@
import logging import logging
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from ..models.notification import ExternalNotificationRequest from capa_de_integracion.dependencies import get_notification_manager
from ..services.notification_manager import NotificationManagerService from capa_de_integracion.models.notification import ExternalNotificationRequest
from ..dependencies import get_notification_manager from capa_de_integracion.services.notification_manager import NotificationManagerService
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1/dialogflow", tags=["notifications"]) router = APIRouter(prefix="/api/v1/dialogflow", tags=["notifications"])
@@ -13,12 +14,11 @@ router = APIRouter(prefix="/api/v1/dialogflow", tags=["notifications"])
@router.post("/notification", status_code=200) @router.post("/notification", status_code=200)
async def process_notification( async def process_notification(
request: ExternalNotificationRequest, request: ExternalNotificationRequest,
notification_manager: NotificationManagerService = Depends( notification_manager: Annotated[NotificationManagerService, Depends(
get_notification_manager get_notification_manager,
), )],
) -> None: ) -> None:
""" """Process push notification from external system.
Process push notification from external system.
This endpoint receives notifications (e.g., "Your card was blocked") and: This endpoint receives notifications (e.g., "Your card was blocked") and:
1. Stores them in Redis/Firestore 1. Stores them in Redis/Firestore
@@ -37,6 +37,7 @@ async def process_notification(
Raises: Raises:
HTTPException: 400 if validation fails, 500 for internal errors HTTPException: 400 if validation fails, 500 for internal errors
""" """
try: try:
logger.info("Received notification request") logger.info("Received notification request")
@@ -45,9 +46,9 @@ async def process_notification(
# Match Java behavior: process but don't return response body # Match Java behavior: process but don't return response body
except ValueError as e: except ValueError as e:
logger.error(f"Validation error: {str(e)}", exc_info=True) logger.error(f"Validation error: {e!s}", exc_info=True)
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
except Exception as e: except Exception as e:
logger.error(f"Error processing notification: {str(e)}", exc_info=True) logger.error(f"Error processing notification: {e!s}", exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error") raise HTTPException(status_code=500, detail="Internal server error")

View File

@@ -1,18 +1,19 @@
import logging import logging
from fastapi import APIRouter, Depends, HTTPException from typing import Annotated
from uuid import uuid4 from uuid import uuid4
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel from pydantic import BaseModel
from ..models.quick_replies import QuickReplyScreen from capa_de_integracion.dependencies import (
from ..services.quick_reply_content import QuickReplyContentService
from ..services.redis_service import RedisService
from ..services.firestore_service import FirestoreService
from ..dependencies import (
get_redis_service,
get_firestore_service, get_firestore_service,
get_quick_reply_content_service, get_quick_reply_content_service,
get_redis_service,
) )
from capa_de_integracion.models.quick_replies import QuickReplyScreen
from capa_de_integracion.services.firestore_service import FirestoreService
from capa_de_integracion.services.quick_reply_content import QuickReplyContentService
from capa_de_integracion.services.redis_service import RedisService
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1/quick-replies", tags=["quick-replies"]) router = APIRouter(prefix="/api/v1/quick-replies", tags=["quick-replies"])
@@ -38,14 +39,13 @@ class QuickReplyScreenResponse(BaseModel):
@router.post("/screen") @router.post("/screen")
async def start_quick_reply_session( async def start_quick_reply_session(
request: QuickReplyScreenRequest, request: QuickReplyScreenRequest,
redis_service: RedisService = Depends(get_redis_service), redis_service: Annotated[RedisService, Depends(get_redis_service)],
firestore_service: FirestoreService = Depends(get_firestore_service), firestore_service: Annotated[FirestoreService, Depends(get_firestore_service)],
quick_reply_content_service: QuickReplyContentService = Depends( quick_reply_content_service: Annotated[QuickReplyContentService, Depends(
get_quick_reply_content_service get_quick_reply_content_service,
), )],
) -> QuickReplyScreenResponse: ) -> QuickReplyScreenResponse:
""" """Start a quick reply FAQ session for a specific screen.
Start a quick reply FAQ session for a specific screen.
Creates a conversation session with pantalla_contexto set, Creates a conversation session with pantalla_contexto set,
loads the quick reply questions for the screen, and returns them. loads the quick reply questions for the screen, and returns them.
@@ -55,39 +55,41 @@ async def start_quick_reply_session(
Returns: Returns:
Detect intent response with quick reply questions Detect intent response with quick reply questions
""" """
try: try:
telefono = request.usuario.telefono telefono = request.usuario.telefono
pantalla_contexto = request.pantallaContexto pantalla_contexto = request.pantallaContexto
if not telefono or not telefono.strip(): if not telefono or not telefono.strip():
raise ValueError("Phone number is required") msg = "Phone number is required"
raise ValueError(msg)
session = await firestore_service.get_session_by_phone(telefono) session = await firestore_service.get_session_by_phone(telefono)
if session: if session:
session_id = session.sessionId session_id = session.sessionId
await firestore_service.update_pantalla_contexto( await firestore_service.update_pantalla_contexto(
session_id, pantalla_contexto session_id, pantalla_contexto,
) )
session.pantallaContexto = pantalla_contexto session.pantallaContexto = pantalla_contexto
else: else:
session_id = str(uuid4()) session_id = str(uuid4())
user_id = f"user_by_phone_{telefono.replace(' ', '').replace('-', '')}" user_id = f"user_by_phone_{telefono.replace(' ', '').replace('-', '')}"
session = await firestore_service.create_session( session = await firestore_service.create_session(
session_id, user_id, telefono, pantalla_contexto session_id, user_id, telefono, pantalla_contexto,
) )
# Cache session # Cache session
await redis_service.save_session(session) await redis_service.save_session(session)
logger.info( logger.info(
f"Created quick reply session {session_id} for screen: {pantalla_contexto}" f"Created quick reply session {session_id} for screen: {pantalla_contexto}",
) )
# Load quick replies # Load quick replies
quick_replies = await quick_reply_content_service.get_quick_replies( quick_replies = await quick_reply_content_service.get_quick_replies(
pantalla_contexto pantalla_contexto,
) )
return QuickReplyScreenResponse( return QuickReplyScreenResponse(
responseId=session_id, quick_replies=quick_replies responseId=session_id, quick_replies=quick_replies,
) )
except ValueError as e: except ValueError as e:

View File

@@ -1,13 +1,13 @@
"""Services module.""" """Services module."""
from .conversation_manager import ConversationManagerService from .conversation_manager import ConversationManagerService
from .notification_manager import NotificationManagerService
from .dlp_service import DLPService from .dlp_service import DLPService
from .notification_manager import NotificationManagerService
from .quick_reply_content import QuickReplyContentService from .quick_reply_content import QuickReplyContentService
__all__ = [ __all__ = [
"QuickReplyContentService",
"ConversationManagerService", "ConversationManagerService",
"NotificationManagerService",
"DLPService", "DLPService",
"NotificationManagerService",
"QuickReplyContentService",
] ]

View File

@@ -1,21 +1,21 @@
import logging import logging
from uuid import uuid4
from datetime import datetime, timedelta from datetime import datetime, timedelta
from uuid import uuid4
from ..config import Settings from capa_de_integracion.config import Settings
from ..models import ( from capa_de_integracion.models import (
ConversationEntry,
ConversationRequest, ConversationRequest,
ConversationSession,
DetectIntentResponse, DetectIntentResponse,
QueryResult, QueryResult,
ConversationSession,
ConversationEntry,
) )
from .redis_service import RedisService
from .firestore_service import FirestoreService
from .dlp_service import DLPService
from .rag_service import RAGService
from .quick_reply_content import QuickReplyContentService
from .dlp_service import DLPService
from .firestore_service import FirestoreService
from .quick_reply_content import QuickReplyContentService
from .rag_service import RAGService
from .redis_service import RedisService
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -35,7 +35,7 @@ class ConversationManagerService:
redis_service: RedisService, redis_service: RedisService,
firestore_service: FirestoreService, firestore_service: FirestoreService,
dlp_service: DLPService, dlp_service: DLPService,
): ) -> None:
"""Initialize conversation manager.""" """Initialize conversation manager."""
self.settings = settings self.settings = settings
self.rag_service = rag_service self.rag_service = rag_service
@@ -47,16 +47,16 @@ class ConversationManagerService:
logger.info("ConversationManagerService initialized successfully") logger.info("ConversationManagerService initialized successfully")
async def manage_conversation( async def manage_conversation(
self, request: ConversationRequest self, request: ConversationRequest,
) -> DetectIntentResponse: ) -> DetectIntentResponse:
""" """Main entry point for managing conversations.
Main entry point for managing conversations.
Args: Args:
request: External conversation request from client request: External conversation request from client
Returns: Returns:
Detect intent response from Dialogflow Detect intent response from Dialogflow
""" """
try: try:
# Step 1: DLP obfuscation # Step 1: DLP obfuscation
@@ -75,7 +75,7 @@ class ConversationManagerService:
session_id = str(uuid4()) session_id = str(uuid4())
user_id = f"user_by_phone_{telefono.replace(' ', '').replace('-', '')}" user_id = f"user_by_phone_{telefono.replace(' ', '').replace('-', '')}"
session = await self.firestore_service.create_session( session = await self.firestore_service.create_session(
session_id, user_id, telefono session_id, user_id, telefono,
) )
await self.redis_service.save_session(session) await self.redis_service.save_session(session)
@@ -85,10 +85,10 @@ class ConversationManagerService:
if self._is_pantalla_context_valid(session.lastModified): if self._is_pantalla_context_valid(session.lastModified):
logger.info( logger.info(
f"Detected 'pantallaContexto' in session: {session.pantallaContexto}. " f"Detected 'pantallaContexto' in session: {session.pantallaContexto}. "
f"Delegating to QuickReplies flow." f"Delegating to QuickReplies flow.",
) )
response = await self._manage_quick_reply_conversation( response = await self._manage_quick_reply_conversation(
request, session.pantallaContexto request, session.pantallaContexto,
) )
if response: if response:
# Save user message to Firestore # Save user message to Firestore
@@ -101,7 +101,7 @@ class ConversationManagerService:
canal=getattr(request, "canal", None), canal=getattr(request, "canal", None),
) )
await self.firestore_service.save_entry( await self.firestore_service.save_entry(
session.sessionId, user_entry session.sessionId, user_entry,
) )
# Save quick reply response to Firestore # Save quick reply response to Firestore
@@ -119,7 +119,7 @@ class ConversationManagerService:
canal=getattr(request, "canal", None), canal=getattr(request, "canal", None),
) )
await self.firestore_service.save_entry( await self.firestore_service.save_entry(
session.sessionId, assistant_entry session.sessionId, assistant_entry,
) )
# Update session with last message and timestamp # Update session with last message and timestamp
@@ -131,14 +131,14 @@ class ConversationManagerService:
return response return response
else: else:
logger.info( logger.info(
"Detected STALE 'pantallaContexto'. Ignoring and proceeding with normal flow." "Detected STALE 'pantallaContexto'. Ignoring and proceeding with normal flow.",
) )
# Step 3: Continue with standard conversation flow # Step 3: Continue with standard conversation flow
nickname = request.usuario.nickname nickname = request.usuario.nickname
logger.info( logger.info(
f"Primary Check (Redis): Looking up session for phone: {telefono}" f"Primary Check (Redis): Looking up session for phone: {telefono}",
) )
# Step 3a: Load conversation history from Firestore # Step 3a: Load conversation history from Firestore
@@ -165,7 +165,7 @@ class ConversationManagerService:
logger.info("Sending query to RAG service") logger.info("Sending query to RAG service")
assistant_response = await self.rag_service.query(messages) assistant_response = await self.rag_service.query(messages)
logger.info( logger.info(
f"Received response from RAG service: {assistant_response[:100]}..." f"Received response from RAG service: {assistant_response[:100]}...",
) )
# Step 3e: Save user message to Firestore # Step 3e: Save user message to Firestore
@@ -205,7 +205,7 @@ class ConversationManagerService:
logger.info(f"Marked {len(notifications)} notifications as processed") logger.info(f"Marked {len(notifications)} notifications as processed")
# Step 3i: Return response object # Step 3i: Return response object
response = DetectIntentResponse( return DetectIntentResponse(
responseId=str(uuid4()), responseId=str(uuid4()),
queryResult=QueryResult( queryResult=QueryResult(
responseText=assistant_response, responseText=assistant_response,
@@ -214,10 +214,9 @@ class ConversationManagerService:
quick_replies=None, quick_replies=None,
) )
return response
except Exception as e: except Exception as e:
logger.error(f"Error managing conversation: {str(e)}", exc_info=True) logger.error(f"Error managing conversation: {e!s}", exc_info=True)
raise raise
def _is_pantalla_context_valid(self, last_modified: datetime) -> bool: def _is_pantalla_context_valid(self, last_modified: datetime) -> bool:
@@ -252,17 +251,16 @@ class ConversationManagerService:
# If no match, use first question as default or delegate to normal flow # If no match, use first question as default or delegate to normal flow
if not matched_answer: if not matched_answer:
logger.warning( logger.warning(
f"No matching quick reply found for message: '{request.mensaje}'." f"No matching quick reply found for message: '{request.mensaje}'.",
) )
# Create response with the matched quick reply answer # Create response with the matched quick reply answer
response = DetectIntentResponse( return DetectIntentResponse(
responseId=str(uuid4()), responseId=str(uuid4()),
queryResult=QueryResult(responseText=matched_answer, parameters=None), queryResult=QueryResult(responseText=matched_answer, parameters=None),
quick_replies=quick_reply_screen, quick_replies=quick_reply_screen,
) )
return response
async def _get_active_notifications(self, telefono: str) -> list: async def _get_active_notifications(self, telefono: str) -> list:
"""Retrieve active notifications for a user from Redis or Firestore. """Retrieve active notifications for a user from Redis or Firestore.
@@ -272,20 +270,21 @@ class ConversationManagerService:
Returns: Returns:
List of active Notification objects List of active Notification objects
""" """
try: try:
# Try Redis first # Try Redis first
notification_session = await self.redis_service.get_notification_session( notification_session = await self.redis_service.get_notification_session(
telefono telefono,
) )
# If not in Redis, try Firestore # If not in Redis, try Firestore
if not notification_session: if not notification_session:
# Firestore uses phone as document ID for notifications # Firestore uses phone as document ID for notifications
from ..models.notification import NotificationSession from capa_de_integracion.models.notification import NotificationSession
doc_ref = self.firestore_service.db.collection( doc_ref = self.firestore_service.db.collection(
self.firestore_service.notifications_collection self.firestore_service.notifications_collection,
).document(telefono) ).document(telefono)
doc = await doc_ref.get() doc = await doc_ref.get()
@@ -295,18 +294,17 @@ class ConversationManagerService:
# Filter for active notifications only # Filter for active notifications only
if notification_session and notification_session.notificaciones: if notification_session and notification_session.notificaciones:
active_notifications = [ return [
notif notif
for notif in notification_session.notificaciones for notif in notification_session.notificaciones
if notif.status == "active" if notif.status == "active"
] ]
return active_notifications
return [] return []
except Exception as e: except Exception as e:
logger.error( logger.error(
f"Error retrieving notifications for {telefono}: {str(e)}", f"Error retrieving notifications for {telefono}: {e!s}",
exc_info=True, exc_info=True,
) )
return [] return []
@@ -330,6 +328,7 @@ class ConversationManagerService:
Returns: Returns:
List of messages in OpenAI format [{"role": "...", "content": "..."}] List of messages in OpenAI format [{"role": "...", "content": "..."}]
""" """
messages = [] messages = []
@@ -341,7 +340,7 @@ class ConversationManagerService:
{ {
"role": "system", "role": "system",
"content": f"Historial de conversación:\n{conversation_context}", "content": f"Historial de conversación:\n{conversation_context}",
} },
) )
# Add system message with notifications if available # Add system message with notifications if available
@@ -355,7 +354,7 @@ class ConversationManagerService:
{ {
"role": "system", "role": "system",
"content": f"Notificaciones pendientes para el usuario:\n{notifications_text}", "content": f"Notificaciones pendientes para el usuario:\n{notifications_text}",
} },
) )
# Add system message with user context # Add system message with user context
@@ -372,11 +371,12 @@ class ConversationManagerService:
Args: Args:
telefono: User phone number telefono: User phone number
""" """
try: try:
# Update status in Firestore # Update status in Firestore
await self.firestore_service.update_notification_status( await self.firestore_service.update_notification_status(
telefono, "processed" telefono, "processed",
) )
# Update or delete from Redis # Update or delete from Redis
@@ -386,7 +386,7 @@ class ConversationManagerService:
except Exception as e: except Exception as e:
logger.error( logger.error(
f"Error marking notifications as processed for {telefono}: {str(e)}", f"Error marking notifications as processed for {telefono}: {e!s}",
exc_info=True, exc_info=True,
) )
@@ -408,13 +408,14 @@ class ConversationManagerService:
Returns: Returns:
Formatted conversation text Formatted conversation text
""" """
if not entries: if not entries:
return "" return ""
# Filter by date (30 days) # Filter by date (30 days)
cutoff_date = datetime.now() - timedelta( cutoff_date = datetime.now() - timedelta(
days=self.settings.conversation_context_days_limit days=self.settings.conversation_context_days_limit,
) )
recent_entries = [ recent_entries = [
e for e in entries if e.timestamp and e.timestamp >= cutoff_date e for e in entries if e.timestamp and e.timestamp >= cutoff_date
@@ -439,6 +440,7 @@ class ConversationManagerService:
Returns: Returns:
Formatted text, truncated if necessary Formatted text, truncated if necessary
""" """
if not entries: if not entries:
return "" return ""
@@ -470,6 +472,7 @@ class ConversationManagerService:
Returns: Returns:
Formatted string (e.g., "User: hello", "Assistant: hi there") Formatted string (e.g., "User: hello", "Assistant: hi there")
""" """
# Map entity to prefix (fixed bug from Java port!) # Map entity to prefix (fixed bug from Java port!)
prefix = "User: " if entry.entity == "user" else "Assistant: " prefix = "User: " if entry.entity == "user" else "Assistant: "

View File

@@ -1,5 +1,4 @@
""" """Copyright 2025 Google. This software is provided as-is, without warranty or
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 representation for any use or purpose. Your use of it is subject to your
agreement with Google. agreement with Google.
@@ -8,29 +7,28 @@ Data Loss Prevention service for obfuscating sensitive information.
import logging import logging
import re import re
from google.cloud import dlp_v2 from google.cloud import dlp_v2
from google.cloud.dlp_v2 import types from google.cloud.dlp_v2 import types
from ..config import Settings from capa_de_integracion.config import Settings
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class DLPService: class DLPService:
""" """Service for detecting and obfuscating sensitive data using Google Cloud DLP.
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, Integrates with the DLP API to scan text for PII and other sensitive information,
then obfuscates findings based on their info type. then obfuscates findings based on their info type.
""" """
def __init__(self, settings: Settings): def __init__(self, settings: Settings) -> None:
""" """Initialize DLP service.
Initialize DLP service.
Args: Args:
settings: Application settings settings: Application settings
""" """
self.settings = settings self.settings = settings
self.project_id = settings.gcp_project_id self.project_id = settings.gcp_project_id
@@ -40,8 +38,7 @@ class DLPService:
logger.info("DLP Service initialized") logger.info("DLP Service initialized")
async def get_obfuscated_string(self, text: str, template_id: str) -> str: async def get_obfuscated_string(self, text: str, template_id: str) -> str:
""" """Inspect text for sensitive data and obfuscate findings.
Inspect text for sensitive data and obfuscate findings.
Args: Args:
text: Text to inspect and obfuscate text: Text to inspect and obfuscate
@@ -52,6 +49,7 @@ class DLPService:
Raises: Raises:
Exception: If DLP API call fails (returns original text on error) Exception: If DLP API call fails (returns original text on error)
""" """
try: try:
# Build content item # Build content item
@@ -63,7 +61,7 @@ class DLPService:
# Build inspect config # Build inspect config
finding_limits = types.InspectConfig.FindingLimits( finding_limits = types.InspectConfig.FindingLimits(
max_findings_per_item=0 # No limit max_findings_per_item=0, # No limit
) )
inspect_config = types.InspectConfig( inspect_config = types.InspectConfig(
@@ -91,7 +89,6 @@ class DLPService:
if findings_count > 0: if findings_count > 0:
return self._obfuscate_text(response, text) return self._obfuscate_text(response, text)
else:
return text return text
except Exception as e: except Exception as e:
@@ -102,8 +99,7 @@ class DLPService:
return text return text
def _obfuscate_text(self, response: types.InspectContentResponse, text: str) -> str: def _obfuscate_text(self, response: types.InspectContentResponse, text: str) -> str:
""" """Obfuscate sensitive findings in text.
Obfuscate sensitive findings in text.
Args: Args:
response: DLP inspect content response with findings response: DLP inspect content response with findings
@@ -111,6 +107,7 @@ class DLPService:
Returns: Returns:
Text with sensitive data obfuscated Text with sensitive data obfuscated
""" """
# Filter findings by likelihood (> POSSIBLE, which is value 3) # Filter findings by likelihood (> POSSIBLE, which is value 3)
findings = [ findings = [
@@ -127,7 +124,7 @@ class DLPService:
info_type = finding.info_type.name info_type = finding.info_type.name
logger.info( logger.info(
f"InfoType: {info_type} | Likelihood: {finding.likelihood.value}" f"InfoType: {info_type} | Likelihood: {finding.likelihood.value}",
) )
# Obfuscate based on info type # Obfuscate based on info type
@@ -136,13 +133,11 @@ class DLPService:
text = text.replace(quote, replacement) text = text.replace(quote, replacement)
# Clean up consecutive DIRECCION tags # Clean up consecutive DIRECCION tags
text = self._clean_direccion(text) return self._clean_direccion(text)
return text
def _get_replacement(self, info_type: str, quote: str) -> str | None: def _get_replacement(self, info_type: str, quote: str) -> str | None:
""" """Get replacement text for a given info type.
Get replacement text for a given info type.
Args: Args:
info_type: DLP info type name info_type: DLP info type name
@@ -150,6 +145,7 @@ class DLPService:
Returns: Returns:
Replacement text or None to skip Replacement text or None to skip
""" """
replacements = { replacements = {
"CREDIT_CARD_NUMBER": f"**** **** **** {self._get_last4(quote)}", "CREDIT_CARD_NUMBER": f"**** **** **** {self._get_last4(quote)}",
@@ -190,7 +186,7 @@ class DLPService:
pattern = r"\[DIRECCION\](?:(?:,\s*|\s+)\[DIRECCION\])*" pattern = r"\[DIRECCION\](?:(?:,\s*|\s+)\[DIRECCION\])*"
return re.sub(pattern, "[DIRECCION]", text).strip() return re.sub(pattern, "[DIRECCION]", text).strip()
async def close(self): async def close(self) -> None:
"""Close DLP client.""" """Close DLP client."""
await self.dlp_client.transport.close() await self.dlp_client.transport.close()
logger.info("DLP client closed") logger.info("DLP client closed")

View File

@@ -1,11 +1,11 @@
import logging import logging
from datetime import datetime from datetime import datetime
from google.cloud import firestore from google.cloud import firestore
from ..config import Settings from capa_de_integracion.config import Settings
from ..models import ConversationSession, ConversationEntry from capa_de_integracion.models import ConversationEntry, ConversationSession
from ..models.notification import Notification from capa_de_integracion.models.notification import Notification
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -13,7 +13,7 @@ logger = logging.getLogger(__name__)
class FirestoreService: class FirestoreService:
"""Service for Firestore operations on conversations.""" """Service for Firestore operations on conversations."""
def __init__(self, settings: Settings): def __init__(self, settings: Settings) -> None:
"""Initialize Firestore client.""" """Initialize Firestore client."""
self.settings = settings self.settings = settings
self.db = firestore.AsyncClient( self.db = firestore.AsyncClient(
@@ -28,10 +28,10 @@ class FirestoreService:
f"artifacts/{settings.gcp_project_id}/notifications" f"artifacts/{settings.gcp_project_id}/notifications"
) )
logger.info( logger.info(
f"Firestore client initialized for project: {settings.gcp_project_id}" f"Firestore client initialized for project: {settings.gcp_project_id}",
) )
async def close(self): async def close(self) -> None:
"""Close Firestore client.""" """Close Firestore client."""
self.db.close() self.db.close()
logger.info("Firestore client closed") logger.info("Firestore client closed")
@@ -56,20 +56,20 @@ class FirestoreService:
return session return session
except Exception as e: except Exception as e:
logger.error( logger.exception(
f"Error retrieving session {session_id} from Firestore: {str(e)}" f"Error retrieving session {session_id} from Firestore: {e!s}",
) )
return None return None
async def get_session_by_phone(self, telefono: str) -> ConversationSession | None: async def get_session_by_phone(self, telefono: str) -> ConversationSession | None:
""" """Retrieve most recent conversation session from Firestore by phone number.
Retrieve most recent conversation session from Firestore by phone number.
Args: Args:
telefono: User phone number telefono: User phone number
Returns: Returns:
Most recent session for this phone, or None if not found Most recent session for this phone, or None if not found
""" """
try: try:
query = ( query = (
@@ -84,7 +84,7 @@ class FirestoreService:
data = doc.to_dict() data = doc.to_dict()
session = ConversationSession.model_validate(data) session = ConversationSession.model_validate(data)
logger.debug( logger.debug(
f"Retrieved session from Firestore for phone {telefono}: {session.sessionId}" f"Retrieved session from Firestore for phone {telefono}: {session.sessionId}",
) )
return session return session
@@ -92,8 +92,8 @@ class FirestoreService:
return None return None
except Exception as e: except Exception as e:
logger.error( logger.exception(
f"Error querying session by phone {telefono} from Firestore: {str(e)}" f"Error querying session by phone {telefono} from Firestore: {e!s}",
) )
return None return None
@@ -107,8 +107,8 @@ class FirestoreService:
return True return True
except Exception as e: except Exception as e:
logger.error( logger.exception(
f"Error saving session {session.sessionId} to Firestore: {str(e)}" f"Error saving session {session.sessionId} to Firestore: {e!s}",
) )
return False return False
@@ -134,6 +134,7 @@ class FirestoreService:
Raises: Raises:
Exception: If session creation or save fails Exception: If session creation or save fails
""" """
session = ConversationSession.create( session = ConversationSession.create(
session_id=session_id, session_id=session_id,
@@ -166,13 +167,13 @@ class FirestoreService:
return True return True
except Exception as e: except Exception as e:
logger.error( logger.exception(
f"Error saving entry for session {session_id} to Firestore: {str(e)}" f"Error saving entry for session {session_id} to Firestore: {e!s}",
) )
return False return False
async def get_entries( async def get_entries(
self, session_id: str, limit: int = 10 self, session_id: str, limit: int = 10,
) -> list[ConversationEntry]: ) -> list[ConversationEntry]:
"""Retrieve recent conversation entries from Firestore.""" """Retrieve recent conversation entries from Firestore."""
try: try:
@@ -181,7 +182,7 @@ class FirestoreService:
# Get entries ordered by timestamp descending # Get entries ordered by timestamp descending
query = entries_ref.order_by( query = entries_ref.order_by(
"timestamp", direction=firestore.Query.DESCENDING "timestamp", direction=firestore.Query.DESCENDING,
).limit(limit) ).limit(limit)
docs = query.stream() docs = query.stream()
@@ -198,8 +199,8 @@ class FirestoreService:
return entries return entries
except Exception as e: except Exception as e:
logger.error( logger.exception(
f"Error retrieving entries for session {session_id} from Firestore: {str(e)}" f"Error retrieving entries for session {session_id} from Firestore: {e!s}",
) )
return [] return []
@@ -219,13 +220,13 @@ class FirestoreService:
return True return True
except Exception as e: except Exception as e:
logger.error( logger.exception(
f"Error deleting session {session_id} from Firestore: {str(e)}" f"Error deleting session {session_id} from Firestore: {e!s}",
) )
return False return False
async def update_pantalla_contexto( async def update_pantalla_contexto(
self, session_id: str, pantalla_contexto: str | None self, session_id: str, pantalla_contexto: str | None,
) -> bool: ) -> bool:
"""Update the pantallaContexto field for a conversation session. """Update the pantallaContexto field for a conversation session.
@@ -235,6 +236,7 @@ class FirestoreService:
Returns: Returns:
True if update was successful, False otherwise True if update was successful, False otherwise
""" """
try: try:
doc_ref = self._session_ref(session_id) doc_ref = self._session_ref(session_id)
@@ -242,7 +244,7 @@ class FirestoreService:
if not doc.exists: if not doc.exists:
logger.warning( logger.warning(
f"Session {session_id} not found in Firestore. Cannot update pantallaContexto" f"Session {session_id} not found in Firestore. Cannot update pantallaContexto",
) )
return False return False
@@ -250,17 +252,17 @@ class FirestoreService:
{ {
"pantallaContexto": pantalla_contexto, "pantallaContexto": pantalla_contexto,
"lastModified": datetime.now(), "lastModified": datetime.now(),
} },
) )
logger.debug( logger.debug(
f"Updated pantallaContexto for session {session_id} in Firestore" f"Updated pantallaContexto for session {session_id} in Firestore",
) )
return True return True
except Exception as e: except Exception as e:
logger.error( logger.exception(
f"Error updating pantallaContexto for session {session_id} in Firestore: {str(e)}" f"Error updating pantallaContexto for session {session_id} in Firestore: {e!s}",
) )
return False return False
@@ -269,22 +271,23 @@ class FirestoreService:
def _notification_ref(self, notification_id: str): def _notification_ref(self, notification_id: str):
"""Get Firestore document reference for notification.""" """Get Firestore document reference for notification."""
return self.db.collection(self.notifications_collection).document( return self.db.collection(self.notifications_collection).document(
notification_id notification_id,
) )
async def save_or_append_notification(self, new_entry: Notification) -> None: async def save_or_append_notification(self, new_entry: Notification) -> None:
""" """Save or append notification entry to Firestore.
Save or append notification entry to Firestore.
Args: Args:
new_entry: Notification entry to save new_entry: Notification entry to save
Raises: Raises:
ValueError: If phone number is missing ValueError: If phone number is missing
""" """
phone_number = new_entry.telefono phone_number = new_entry.telefono
if not phone_number or not phone_number.strip(): if not phone_number or not phone_number.strip():
raise ValueError("Phone number is required to manage notification entries") msg = "Phone number is required to manage notification entries"
raise ValueError(msg)
# Use phone number as document ID # Use phone number as document ID
notification_session_id = phone_number notification_session_id = phone_number
@@ -301,10 +304,10 @@ class FirestoreService:
{ {
"notificaciones": firestore.ArrayUnion([entry_dict]), "notificaciones": firestore.ArrayUnion([entry_dict]),
"ultimaActualizacion": datetime.now(), "ultimaActualizacion": datetime.now(),
} },
) )
logger.info( logger.info(
f"Successfully appended notification entry to session {notification_session_id} in Firestore" f"Successfully appended notification entry to session {notification_session_id} in Firestore",
) )
else: else:
# Create new notification session # Create new notification session
@@ -317,23 +320,23 @@ class FirestoreService:
} }
await doc_ref.set(new_session_data) await doc_ref.set(new_session_data)
logger.info( logger.info(
f"Successfully created new notification session {notification_session_id} in Firestore" f"Successfully created new notification session {notification_session_id} in Firestore",
) )
except Exception as e: except Exception as e:
logger.error( logger.error(
f"Error saving notification to Firestore for phone {phone_number}: {str(e)}", f"Error saving notification to Firestore for phone {phone_number}: {e!s}",
exc_info=True, exc_info=True,
) )
raise raise
async def update_notification_status(self, session_id: str, status: str) -> None: async def update_notification_status(self, session_id: str, status: str) -> None:
""" """Update the status of all notifications in a session.
Update the status of all notifications in a session.
Args: Args:
session_id: Notification session ID (phone number) session_id: Notification session ID (phone number)
status: New status value status: New status value
""" """
try: try:
doc_ref = self._notification_ref(session_id) doc_ref = self._notification_ref(session_id)
@@ -341,7 +344,7 @@ class FirestoreService:
if not doc.exists: if not doc.exists:
logger.warning( logger.warning(
f"Notification session {session_id} not found in Firestore. Cannot update status" f"Notification session {session_id} not found in Firestore. Cannot update status",
) )
return return
@@ -357,16 +360,16 @@ class FirestoreService:
{ {
"notificaciones": updated_notifications, "notificaciones": updated_notifications,
"ultimaActualizacion": datetime.now(), "ultimaActualizacion": datetime.now(),
} },
) )
logger.info( logger.info(
f"Successfully updated notification status to '{status}' for session {session_id} in Firestore" f"Successfully updated notification status to '{status}' for session {session_id} in Firestore",
) )
except Exception as e: except Exception as e:
logger.error( logger.error(
f"Error updating notification status in Firestore for session {session_id}: {str(e)}", f"Error updating notification status in Firestore for session {session_id}: {e!s}",
exc_info=True, exc_info=True,
) )
raise raise
@@ -375,18 +378,18 @@ class FirestoreService:
"""Delete notification session from Firestore.""" """Delete notification session from Firestore."""
try: try:
logger.info( logger.info(
f"Deleting notification session {notification_id} from Firestore" f"Deleting notification session {notification_id} from Firestore",
) )
doc_ref = self._notification_ref(notification_id) doc_ref = self._notification_ref(notification_id)
await doc_ref.delete() await doc_ref.delete()
logger.info( logger.info(
f"Successfully deleted notification session {notification_id} from Firestore" f"Successfully deleted notification session {notification_id} from Firestore",
) )
return True return True
except Exception as e: except Exception as e:
logger.error( logger.error(
f"Error deleting notification session {notification_id} from Firestore: {str(e)}", f"Error deleting notification session {notification_id} from Firestore: {e!s}",
exc_info=True, exc_info=True,
) )
return False return False

View File

@@ -2,12 +2,15 @@ import asyncio
import logging import logging
from uuid import uuid4 from uuid import uuid4
from ..config import Settings from capa_de_integracion.config import Settings
from ..models.notification import ExternalNotificationRequest, Notification from capa_de_integracion.models.notification import (
from .redis_service import RedisService ExternalNotificationRequest,
from .firestore_service import FirestoreService Notification,
from .dlp_service import DLPService )
from .dlp_service import DLPService
from .firestore_service import FirestoreService
from .redis_service import RedisService
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -15,8 +18,7 @@ PREFIX_PO_PARAM = "notification_po_"
class NotificationManagerService: class NotificationManagerService:
""" """Manages notification processing and integration with conversations.
Manages notification processing and integration with conversations.
Handles push notifications from external systems, stores them in Handles push notifications from external systems, stores them in
Redis/Firestore, and triggers Dialogflow event detection. Redis/Firestore, and triggers Dialogflow event detection.
@@ -28,9 +30,8 @@ class NotificationManagerService:
redis_service: RedisService, redis_service: RedisService,
firestore_service: FirestoreService, firestore_service: FirestoreService,
dlp_service: DLPService, dlp_service: DLPService,
): ) -> None:
""" """Initialize notification manager.
Initialize notification manager.
Args: Args:
settings: Application settings settings: Application settings
@@ -38,6 +39,7 @@ class NotificationManagerService:
redis_service: Redis caching service redis_service: Redis caching service
firestore_service: Firestore persistence service firestore_service: Firestore persistence service
dlp_service: Data Loss Prevention service dlp_service: Data Loss Prevention service
""" """
self.settings = settings self.settings = settings
self.redis_service = redis_service self.redis_service = redis_service
@@ -49,10 +51,9 @@ class NotificationManagerService:
logger.info("NotificationManagerService initialized") logger.info("NotificationManagerService initialized")
async def process_notification( async def process_notification(
self, external_request: ExternalNotificationRequest self, external_request: ExternalNotificationRequest,
) -> None: ) -> None:
""" """Process a push notification from external system.
Process a push notification from external system.
Flow: Flow:
1. Validate phone number 1. Validate phone number
@@ -71,6 +72,7 @@ class NotificationManagerService:
Raises: Raises:
ValueError: If phone number is missing ValueError: If phone number is missing
""" """
telefono = external_request.telefono telefono = external_request.telefono
@@ -101,17 +103,17 @@ class NotificationManagerService:
# Save notification to Redis (with async Firestore write-back) # Save notification to Redis (with async Firestore write-back)
await self.redis_service.save_or_append_notification(new_notification_entry) await self.redis_service.save_or_append_notification(new_notification_entry)
logger.info( logger.info(
f"Notification for phone {telefono} cached. Kicking off async Firestore write-back" f"Notification for phone {telefono} cached. Kicking off async Firestore write-back",
) )
# Fire-and-forget Firestore write (matching Java's .subscribe() behavior) # Fire-and-forget Firestore write (matching Java's .subscribe() behavior)
async def save_notification_to_firestore(): async def save_notification_to_firestore() -> None:
try: try:
await self.firestore_service.save_or_append_notification( await self.firestore_service.save_or_append_notification(
new_notification_entry new_notification_entry,
) )
logger.debug( logger.debug(
f"Notification entry persisted to Firestore for phone {telefono}" f"Notification entry persisted to Firestore for phone {telefono}",
) )
except Exception as e: except Exception as e:
logger.error( logger.error(

View File

@@ -1,9 +1,11 @@
import json import json
import logging import logging
from ..config import Settings from capa_de_integracion.config import Settings
from ..models.quick_replies import QuickReplyScreen, QuickReplyQuestions from capa_de_integracion.models.quick_replies import (
QuickReplyQuestions,
QuickReplyScreen,
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -11,23 +13,22 @@ logger = logging.getLogger(__name__)
class QuickReplyContentService: class QuickReplyContentService:
"""Service for loading quick reply screen content from JSON files.""" """Service for loading quick reply screen content from JSON files."""
def __init__(self, settings: Settings): def __init__(self, settings: Settings) -> None:
""" """Initialize quick reply content service.
Initialize quick reply content service.
Args: Args:
settings: Application settings settings: Application settings
""" """
self.settings = settings self.settings = settings
self.quick_replies_path = settings.base_path / "quick_replies" self.quick_replies_path = settings.base_path / "quick_replies"
logger.info( logger.info(
f"QuickReplyContentService initialized with path: {self.quick_replies_path}" f"QuickReplyContentService initialized with path: {self.quick_replies_path}",
) )
async def get_quick_replies(self, screen_id: str) -> QuickReplyScreen: async def get_quick_replies(self, screen_id: str) -> QuickReplyScreen:
""" """Load quick reply screen content by ID.
Load quick reply screen content by ID.
Args: Args:
screen_id: Screen identifier (e.g., "pagos", "home") screen_id: Screen identifier (e.g., "pagos", "home")
@@ -37,6 +38,7 @@ class QuickReplyContentService:
Raises: Raises:
ValueError: If the quick reply file is not found ValueError: If the quick reply file is not found
""" """
if not screen_id or not screen_id.strip(): if not screen_id or not screen_id.strip():
logger.warning("screen_id is null or empty. Returning empty quick replies") logger.warning("screen_id is null or empty. Returning empty quick replies")
@@ -53,11 +55,12 @@ class QuickReplyContentService:
try: try:
if not file_path.exists(): if not file_path.exists():
logger.warning(f"Quick reply file not found: {file_path}") logger.warning(f"Quick reply file not found: {file_path}")
msg = f"Quick reply file not found for screen_id: {screen_id}"
raise ValueError( raise ValueError(
f"Quick reply file not found for screen_id: {screen_id}" msg,
) )
with open(file_path, "r", encoding="utf-8") as f: with open(file_path, encoding="utf-8") as f:
data = json.load(f) data = json.load(f)
# Parse questions # Parse questions
@@ -80,20 +83,22 @@ class QuickReplyContentService:
) )
logger.info( logger.info(
f"Successfully loaded {len(preguntas)} quick replies for screen: {screen_id}" f"Successfully loaded {len(preguntas)} quick replies for screen: {screen_id}",
) )
return quick_reply return quick_reply
except json.JSONDecodeError as e: except json.JSONDecodeError as e:
logger.error(f"Error parsing JSON file {file_path}: {e}", exc_info=True) logger.error(f"Error parsing JSON file {file_path}: {e}", exc_info=True)
msg = f"Invalid JSON format in quick reply file for screen_id: {screen_id}"
raise ValueError( raise ValueError(
f"Invalid JSON format in quick reply file for screen_id: {screen_id}" msg,
) from e ) from e
except Exception as e: except Exception as e:
logger.error( logger.error(
f"Error loading quick replies for screen {screen_id}: {e}", f"Error loading quick replies for screen {screen_id}: {e}",
exc_info=True, exc_info=True,
) )
msg = f"Error loading quick replies for screen_id: {screen_id}"
raise ValueError( raise ValueError(
f"Error loading quick replies for screen_id: {screen_id}" msg,
) from e ) from e

View File

@@ -1,9 +1,9 @@
import logging import logging
import httpx import httpx
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from ..config import Settings from capa_de_integracion.config import Settings
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -28,8 +28,7 @@ class RAGResponse(BaseModel):
class RAGService: class RAGService:
""" """Highly concurrent HTTP client for calling RAG endpoints.
Highly concurrent HTTP client for calling RAG endpoints.
Uses httpx AsyncClient with connection pooling for optimal performance Uses httpx AsyncClient with connection pooling for optimal performance
when handling multiple concurrent requests. when handling multiple concurrent requests.
@@ -41,15 +40,15 @@ class RAGService:
max_connections: int = 100, max_connections: int = 100,
max_keepalive_connections: int = 20, max_keepalive_connections: int = 20,
timeout: float = 30.0, timeout: float = 30.0,
): ) -> None:
""" """Initialize RAG service with connection pooling.
Initialize RAG service with connection pooling.
Args: Args:
settings: Application settings settings: Application settings
max_connections: Maximum number of concurrent connections max_connections: Maximum number of concurrent connections
max_keepalive_connections: Maximum number of idle connections to keep alive max_keepalive_connections: Maximum number of idle connections to keep alive
timeout: Request timeout in seconds timeout: Request timeout in seconds
""" """
self.settings = settings self.settings = settings
self.rag_endpoint_url = settings.rag_endpoint_url self.rag_endpoint_url = settings.rag_endpoint_url
@@ -70,12 +69,11 @@ class RAGService:
logger.info( logger.info(
f"RAGService initialized with endpoint: {self.rag_endpoint_url}, " f"RAGService initialized with endpoint: {self.rag_endpoint_url}, "
f"max_connections: {max_connections}, timeout: {timeout}s" f"max_connections: {max_connections}, timeout: {timeout}s",
) )
async def query(self, messages: list[dict[str, str]]) -> str: async def query(self, messages: list[dict[str, str]]) -> str:
""" """Send conversation history to RAG endpoint and get response.
Send conversation history to RAG endpoint and get response.
Args: Args:
messages: OpenAI-style conversation history messages: OpenAI-style conversation history
@@ -87,6 +85,7 @@ class RAGService:
Raises: Raises:
httpx.HTTPError: If HTTP request fails httpx.HTTPError: If HTTP request fails
ValueError: If response format is invalid ValueError: If response format is invalid
""" """
try: try:
# Validate and construct request # Validate and construct request
@@ -113,20 +112,20 @@ class RAGService:
return rag_response.response return rag_response.response
except httpx.HTTPStatusError as e: except httpx.HTTPStatusError as e:
logger.error( logger.exception(
f"HTTP error calling RAG endpoint: {e.response.status_code} - {e.response.text}" f"HTTP error calling RAG endpoint: {e.response.status_code} - {e.response.text}",
) )
raise raise
except httpx.RequestError as e: except httpx.RequestError as e:
logger.error(f"Request error calling RAG endpoint: {str(e)}") logger.exception(f"Request error calling RAG endpoint: {e!s}")
raise raise
except Exception as e: except Exception as e:
logger.error( logger.error(
f"Unexpected error calling RAG endpoint: {str(e)}", exc_info=True f"Unexpected error calling RAG endpoint: {e!s}", exc_info=True,
) )
raise raise
async def close(self): async def close(self) -> None:
"""Close the HTTP client and release connections.""" """Close the HTTP client and release connections."""
await self._client.aclose() await self._client.aclose()
logger.info("RAGService client closed") logger.info("RAGService client closed")

View File

@@ -1,12 +1,12 @@
import json import json
import logging import logging
from datetime import datetime from datetime import datetime
from redis.asyncio import Redis from redis.asyncio import Redis
from ..config import Settings from capa_de_integracion.config import Settings
from ..models import ConversationSession from capa_de_integracion.models import ConversationSession
from ..models.notification import NotificationSession, Notification from capa_de_integracion.models.notification import Notification, NotificationSession
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -14,7 +14,7 @@ logger = logging.getLogger(__name__)
class RedisService: class RedisService:
"""Service for Redis operations on conversation sessions.""" """Service for Redis operations on conversation sessions."""
def __init__(self, settings: Settings): def __init__(self, settings: Settings) -> None:
"""Initialize Redis client.""" """Initialize Redis client."""
self.settings = settings self.settings = settings
self.redis: Redis | None = None self.redis: Redis | None = None
@@ -22,7 +22,7 @@ class RedisService:
self.notification_ttl = 2592000 # 30 days in seconds self.notification_ttl = 2592000 # 30 days in seconds
self.qr_session_ttl = 86400 # 24 hours in seconds self.qr_session_ttl = 86400 # 24 hours in seconds
async def connect(self): async def connect(self) -> None:
"""Connect to Redis.""" """Connect to Redis."""
self.redis = Redis( self.redis = Redis(
host=self.settings.redis_host, host=self.settings.redis_host,
@@ -31,10 +31,10 @@ class RedisService:
decode_responses=True, decode_responses=True,
) )
logger.info( logger.info(
f"Connected to Redis at {self.settings.redis_host}:{self.settings.redis_port}" f"Connected to Redis at {self.settings.redis_host}:{self.settings.redis_port}",
) )
async def close(self): async def close(self) -> None:
"""Close Redis connection.""" """Close Redis connection."""
if self.redis: if self.redis:
await self.redis.close() await self.redis.close()
@@ -49,17 +49,18 @@ class RedisService:
return f"conversation:phone:{phone}" return f"conversation:phone:{phone}"
async def get_session(self, session_id_or_phone: str) -> ConversationSession | None: async def get_session(self, session_id_or_phone: str) -> ConversationSession | None:
""" """Retrieve conversation session from Redis by session ID or phone number.
Retrieve conversation session from Redis by session ID or phone number.
Args: Args:
session_id_or_phone: Either a session ID or phone number session_id_or_phone: Either a session ID or phone number
Returns: Returns:
Conversation session or None if not found Conversation session or None if not found
""" """
if not self.redis: if not self.redis:
raise RuntimeError("Redis client not connected") msg = "Redis client not connected"
raise RuntimeError(msg)
# First try as phone number (lookup session ID) # First try as phone number (lookup session ID)
phone_key = self._phone_to_session_key(session_id_or_phone) phone_key = self._phone_to_session_key(session_id_or_phone)
@@ -86,17 +87,17 @@ class RedisService:
logger.debug(f"Retrieved session from Redis: {session_id}") logger.debug(f"Retrieved session from Redis: {session_id}")
return session return session
except Exception as e: except Exception as e:
logger.error(f"Error deserializing session {session_id}: {str(e)}") logger.exception(f"Error deserializing session {session_id}: {e!s}")
return None return None
async def save_session(self, session: ConversationSession) -> bool: async def save_session(self, session: ConversationSession) -> bool:
""" """Save conversation session to Redis with TTL.
Save conversation session to Redis with TTL.
Also stores phone-to-session mapping for lookup by phone number. Also stores phone-to-session mapping for lookup by phone number.
""" """
if not self.redis: if not self.redis:
raise RuntimeError("Redis client not connected") msg = "Redis client not connected"
raise RuntimeError(msg)
key = self._session_key(session.sessionId) key = self._session_key(session.sessionId)
phone_key = self._phone_to_session_key(session.telefono) phone_key = self._phone_to_session_key(session.telefono)
@@ -110,17 +111,18 @@ class RedisService:
await self.redis.setex(phone_key, self.session_ttl, session.sessionId) await self.redis.setex(phone_key, self.session_ttl, session.sessionId)
logger.debug( logger.debug(
f"Saved session to Redis: {session.sessionId} for phone: {session.telefono}" f"Saved session to Redis: {session.sessionId} for phone: {session.telefono}",
) )
return True return True
except Exception as e: except Exception as e:
logger.error(f"Error saving session {session.sessionId} to Redis: {str(e)}") logger.exception(f"Error saving session {session.sessionId} to Redis: {e!s}")
return False return False
async def delete_session(self, session_id: str) -> bool: async def delete_session(self, session_id: str) -> bool:
"""Delete conversation session from Redis.""" """Delete conversation session from Redis."""
if not self.redis: if not self.redis:
raise RuntimeError("Redis client not connected") msg = "Redis client not connected"
raise RuntimeError(msg)
key = self._session_key(session_id) key = self._session_key(session_id)
@@ -129,13 +131,14 @@ class RedisService:
logger.debug(f"Deleted session from Redis: {session_id}") logger.debug(f"Deleted session from Redis: {session_id}")
return result > 0 return result > 0
except Exception as e: except Exception as e:
logger.error(f"Error deleting session {session_id} from Redis: {str(e)}") logger.exception(f"Error deleting session {session_id} from Redis: {e!s}")
return False return False
async def exists(self, session_id: str) -> bool: async def exists(self, session_id: str) -> bool:
"""Check if session exists in Redis.""" """Check if session exists in Redis."""
if not self.redis: if not self.redis:
raise RuntimeError("Redis client not connected") msg = "Redis client not connected"
raise RuntimeError(msg)
key = self._session_key(session_id) key = self._session_key(session_id)
return await self.redis.exists(key) > 0 return await self.redis.exists(key) > 0
@@ -147,8 +150,7 @@ class RedisService:
return f"conversation:messages:{session_id}" return f"conversation:messages:{session_id}"
async def save_message(self, session_id: str, message) -> bool: async def save_message(self, session_id: str, message) -> bool:
""" """Save a conversation message to Redis sorted set.
Save a conversation message to Redis sorted set.
Messages are stored in a sorted set with timestamp as score. Messages are stored in a sorted set with timestamp as score.
@@ -158,9 +160,11 @@ class RedisService:
Returns: Returns:
True if successful, False otherwise True if successful, False otherwise
""" """
if not self.redis: if not self.redis:
raise RuntimeError("Redis client not connected") msg = "Redis client not connected"
raise RuntimeError(msg)
key = self._messages_key(session_id) key = self._messages_key(session_id)
@@ -178,14 +182,13 @@ class RedisService:
logger.debug(f"Saved message to Redis: {session_id}") logger.debug(f"Saved message to Redis: {session_id}")
return True return True
except Exception as e: except Exception as e:
logger.error( logger.exception(
f"Error saving message to Redis for session {session_id}: {str(e)}" f"Error saving message to Redis for session {session_id}: {e!s}",
) )
return False return False
async def get_messages(self, session_id: str) -> list: async def get_messages(self, session_id: str) -> list:
""" """Retrieve all conversation messages for a session from Redis.
Retrieve all conversation messages for a session from Redis.
Returns messages ordered by timestamp (oldest first). Returns messages ordered by timestamp (oldest first).
@@ -194,9 +197,11 @@ class RedisService:
Returns: Returns:
List of message dictionaries (parsed from JSON) List of message dictionaries (parsed from JSON)
""" """
if not self.redis: if not self.redis:
raise RuntimeError("Redis client not connected") msg = "Redis client not connected"
raise RuntimeError(msg)
key = self._messages_key(session_id) key = self._messages_key(session_id)
@@ -214,16 +219,16 @@ class RedisService:
try: try:
messages.append(json.loads(msg_str)) messages.append(json.loads(msg_str))
except json.JSONDecodeError as e: except json.JSONDecodeError as e:
logger.error(f"Error parsing message JSON: {str(e)}") logger.exception(f"Error parsing message JSON: {e!s}")
continue continue
logger.debug( logger.debug(
f"Retrieved {len(messages)} messages from Redis for session: {session_id}" f"Retrieved {len(messages)} messages from Redis for session: {session_id}",
) )
return messages return messages
except Exception as e: except Exception as e:
logger.error( logger.exception(
f"Error retrieving messages from Redis for session {session_id}: {str(e)}" f"Error retrieving messages from Redis for session {session_id}: {e!s}",
) )
return [] return []
@@ -238,21 +243,23 @@ class RedisService:
return f"notification:phone_to_notification:{phone}" return f"notification:phone_to_notification:{phone}"
async def save_or_append_notification(self, new_entry: Notification) -> None: async def save_or_append_notification(self, new_entry: Notification) -> None:
""" """Save or append notification entry to session.
Save or append notification entry to session.
Args: Args:
new_entry: Notification entry to save new_entry: Notification entry to save
Raises: Raises:
ValueError: If phone number is missing ValueError: If phone number is missing
""" """
if not self.redis: if not self.redis:
raise RuntimeError("Redis client not connected") msg = "Redis client not connected"
raise RuntimeError(msg)
phone_number = new_entry.telefono phone_number = new_entry.telefono
if not phone_number or not phone_number.strip(): if not phone_number or not phone_number.strip():
raise ValueError("Phone number is required to manage notification entries") msg = "Phone number is required to manage notification entries"
raise ValueError(msg)
# Use phone number as session ID for notifications # Use phone number as session ID for notifications
notification_session_id = phone_number notification_session_id = phone_number
@@ -262,7 +269,7 @@ class RedisService:
if existing_session: if existing_session:
# Append to existing session # Append to existing session
updated_notifications = existing_session.notificaciones + [new_entry] updated_notifications = [*existing_session.notificaciones, new_entry]
updated_session = NotificationSession( updated_session = NotificationSession(
sessionId=notification_session_id, sessionId=notification_session_id,
telefono=phone_number, telefono=phone_number,
@@ -286,7 +293,8 @@ class RedisService:
async def _cache_notification_session(self, session: NotificationSession) -> bool: async def _cache_notification_session(self, session: NotificationSession) -> bool:
"""Cache notification session in Redis.""" """Cache notification session in Redis."""
if not self.redis: if not self.redis:
raise RuntimeError("Redis client not connected") msg = "Redis client not connected"
raise RuntimeError(msg)
key = self._notification_key(session.sessionId) key = self._notification_key(session.sessionId)
phone_key = self._phone_to_notification_key(session.telefono) phone_key = self._phone_to_notification_key(session.telefono)
@@ -302,17 +310,18 @@ class RedisService:
logger.debug(f"Cached notification session: {session.sessionId}") logger.debug(f"Cached notification session: {session.sessionId}")
return True return True
except Exception as e: except Exception as e:
logger.error( logger.exception(
f"Error caching notification session {session.sessionId}: {str(e)}" f"Error caching notification session {session.sessionId}: {e!s}",
) )
return False return False
async def get_notification_session( async def get_notification_session(
self, session_id: str self, session_id: str,
) -> NotificationSession | None: ) -> NotificationSession | None:
"""Retrieve notification session from Redis.""" """Retrieve notification session from Redis."""
if not self.redis: if not self.redis:
raise RuntimeError("Redis client not connected") msg = "Redis client not connected"
raise RuntimeError(msg)
key = self._notification_key(session_id) key = self._notification_key(session_id)
data = await self.redis.get(key) data = await self.redis.get(key)
@@ -327,15 +336,16 @@ class RedisService:
logger.info(f"Notification session {session_id} retrieved from Redis") logger.info(f"Notification session {session_id} retrieved from Redis")
return session return session
except Exception as e: except Exception as e:
logger.error( logger.exception(
f"Error deserializing notification session {session_id}: {str(e)}" f"Error deserializing notification session {session_id}: {e!s}",
) )
return None return None
async def get_notification_id_for_phone(self, phone: str) -> str | None: async def get_notification_id_for_phone(self, phone: str) -> str | None:
"""Get notification session ID for a phone number.""" """Get notification session ID for a phone number."""
if not self.redis: if not self.redis:
raise RuntimeError("Redis client not connected") msg = "Redis client not connected"
raise RuntimeError(msg)
key = self._phone_to_notification_key(phone) key = self._phone_to_notification_key(phone)
session_id = await self.redis.get(key) session_id = await self.redis.get(key)
@@ -350,7 +360,8 @@ class RedisService:
async def delete_notification_session(self, phone_number: str) -> bool: async def delete_notification_session(self, phone_number: str) -> bool:
"""Delete notification session from Redis.""" """Delete notification session from Redis."""
if not self.redis: if not self.redis:
raise RuntimeError("Redis client not connected") msg = "Redis client not connected"
raise RuntimeError(msg)
notification_key = self._notification_key(phone_number) notification_key = self._notification_key(phone_number)
phone_key = self._phone_to_notification_key(phone_number) phone_key = self._phone_to_notification_key(phone_number)
@@ -361,7 +372,7 @@ class RedisService:
await self.redis.delete(phone_key) await self.redis.delete(phone_key)
return True return True
except Exception as e: except Exception as e:
logger.error( logger.exception(
f"Error deleting notification session for phone {phone_number}: {str(e)}" f"Error deleting notification session for phone {phone_number}: {e!s}",
) )
return False return False