.
This commit is contained in:
@@ -4,13 +4,10 @@ from .conversation_manager import ConversationManagerService
|
||||
from .notification_manager import NotificationManagerService
|
||||
from .dlp_service import DLPService
|
||||
from .quick_reply_content import QuickReplyContentService
|
||||
from .mappers import NotificationContextMapper, ConversationContextMapper
|
||||
|
||||
__all__ = [
|
||||
"QuickReplyContentService",
|
||||
"ConversationManagerService",
|
||||
"NotificationManagerService",
|
||||
"DLPService",
|
||||
"NotificationContextMapper",
|
||||
"ConversationContextMapper",
|
||||
]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,7 @@ from datetime import datetime
|
||||
from google.cloud import firestore
|
||||
|
||||
from ..config import Settings
|
||||
from ..models import ConversationSessionDTO, ConversationEntryDTO
|
||||
from ..models import ConversationSession, ConversationEntry
|
||||
from ..models.notification import Notification
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ class FirestoreService:
|
||||
"""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:
|
||||
async def get_session(self, session_id: str) -> ConversationSession | None:
|
||||
"""Retrieve conversation session from Firestore by session ID."""
|
||||
try:
|
||||
doc_ref = self._session_ref(session_id)
|
||||
@@ -51,7 +51,7 @@ class FirestoreService:
|
||||
return None
|
||||
|
||||
data = doc.to_dict()
|
||||
session = ConversationSessionDTO.model_validate(data)
|
||||
session = ConversationSession.model_validate(data)
|
||||
logger.debug(f"Retrieved session from Firestore: {session_id}")
|
||||
return session
|
||||
|
||||
@@ -61,9 +61,7 @@ class FirestoreService:
|
||||
)
|
||||
return None
|
||||
|
||||
async def get_session_by_phone(
|
||||
self, telefono: str
|
||||
) -> ConversationSessionDTO | None:
|
||||
async def get_session_by_phone(self, telefono: str) -> ConversationSession | None:
|
||||
"""
|
||||
Retrieve most recent conversation session from Firestore by phone number.
|
||||
|
||||
@@ -84,7 +82,7 @@ class FirestoreService:
|
||||
docs = query.stream()
|
||||
async for doc in docs:
|
||||
data = doc.to_dict()
|
||||
session = ConversationSessionDTO.model_validate(data)
|
||||
session = ConversationSession.model_validate(data)
|
||||
logger.debug(
|
||||
f"Retrieved session from Firestore for phone {telefono}: {session.sessionId}"
|
||||
)
|
||||
@@ -99,7 +97,7 @@ class FirestoreService:
|
||||
)
|
||||
return None
|
||||
|
||||
async def save_session(self, session: ConversationSessionDTO) -> bool:
|
||||
async def save_session(self, session: ConversationSession) -> bool:
|
||||
"""Save conversation session to Firestore."""
|
||||
try:
|
||||
doc_ref = self._session_ref(session.sessionId)
|
||||
@@ -121,7 +119,7 @@ class FirestoreService:
|
||||
telefono: str,
|
||||
pantalla_contexto: str | None = None,
|
||||
last_message: str | None = None,
|
||||
) -> ConversationSessionDTO:
|
||||
) -> ConversationSession:
|
||||
"""Create and save a new conversation session to Firestore.
|
||||
|
||||
Args:
|
||||
@@ -137,7 +135,7 @@ class FirestoreService:
|
||||
Raises:
|
||||
Exception: If session creation or save fails
|
||||
"""
|
||||
session = ConversationSessionDTO.create(
|
||||
session = ConversationSession.create(
|
||||
session_id=session_id,
|
||||
user_id=user_id,
|
||||
telefono=telefono,
|
||||
@@ -152,7 +150,7 @@ class FirestoreService:
|
||||
logger.info(f"Created new session in Firestore: {session_id}")
|
||||
return session
|
||||
|
||||
async def save_entry(self, session_id: str, entry: ConversationEntryDTO) -> bool:
|
||||
async def save_entry(self, session_id: str, entry: ConversationEntry) -> bool:
|
||||
"""Save conversation entry to Firestore subcollection."""
|
||||
try:
|
||||
doc_ref = self._session_ref(session_id)
|
||||
@@ -175,7 +173,7 @@ class FirestoreService:
|
||||
|
||||
async def get_entries(
|
||||
self, session_id: str, limit: int = 10
|
||||
) -> list[ConversationEntryDTO]:
|
||||
) -> list[ConversationEntry]:
|
||||
"""Retrieve recent conversation entries from Firestore."""
|
||||
try:
|
||||
doc_ref = self._session_ref(session_id)
|
||||
@@ -191,7 +189,7 @@ class FirestoreService:
|
||||
|
||||
async for doc in docs:
|
||||
entry_data = doc.to_dict()
|
||||
entry = ConversationEntryDTO.model_validate(entry_data)
|
||||
entry = ConversationEntry.model_validate(entry_data)
|
||||
entries.append(entry)
|
||||
|
||||
# Reverse to get chronological order
|
||||
|
||||
@@ -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.
|
||||
|
||||
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 Notification
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class NotificationContextMapper:
|
||||
"""Maps notifications to text format for Gemini classification."""
|
||||
|
||||
@staticmethod
|
||||
def to_text(notification: Notification) -> 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[Notification]) -> 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: Notification) -> 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()
|
||||
@@ -53,7 +53,9 @@ class QuickReplyContentService:
|
||||
try:
|
||||
if not file_path.exists():
|
||||
logger.warning(f"Quick reply file not found: {file_path}")
|
||||
raise ValueError(f"Quick reply file not found for screen_id: {screen_id}")
|
||||
raise ValueError(
|
||||
f"Quick reply file not found for screen_id: {screen_id}"
|
||||
)
|
||||
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
@@ -84,10 +86,14 @@ class QuickReplyContentService:
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"Error parsing JSON file {file_path}: {e}", exc_info=True)
|
||||
raise ValueError(f"Invalid JSON format in quick reply file for screen_id: {screen_id}") from e
|
||||
raise ValueError(
|
||||
f"Invalid JSON format in quick reply file for screen_id: {screen_id}"
|
||||
) from e
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error loading quick replies for screen {screen_id}: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
raise ValueError(f"Error loading quick replies for screen_id: {screen_id}") from e
|
||||
raise ValueError(
|
||||
f"Error loading quick replies for screen_id: {screen_id}"
|
||||
) from e
|
||||
|
||||
@@ -121,7 +121,9 @@ class RAGService:
|
||||
logger.error(f"Request error calling RAG endpoint: {str(e)}")
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error calling RAG endpoint: {str(e)}", exc_info=True)
|
||||
logger.error(
|
||||
f"Unexpected error calling RAG endpoint: {str(e)}", exc_info=True
|
||||
)
|
||||
raise
|
||||
|
||||
async def close(self):
|
||||
|
||||
@@ -4,7 +4,7 @@ from datetime import datetime
|
||||
from redis.asyncio import Redis
|
||||
|
||||
from ..config import Settings
|
||||
from ..models import ConversationSessionDTO
|
||||
from ..models import ConversationSession
|
||||
from ..models.notification import NotificationSession, Notification
|
||||
|
||||
|
||||
@@ -48,9 +48,7 @@ class RedisService:
|
||||
"""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:
|
||||
async def get_session(self, session_id_or_phone: str) -> ConversationSession | None:
|
||||
"""
|
||||
Retrieve conversation session from Redis by session ID or phone number.
|
||||
|
||||
@@ -84,14 +82,14 @@ class RedisService:
|
||||
|
||||
try:
|
||||
session_dict = json.loads(data)
|
||||
session = ConversationSessionDTO.model_validate(session_dict)
|
||||
session = ConversationSession.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:
|
||||
async def save_session(self, session: ConversationSession) -> bool:
|
||||
"""
|
||||
Save conversation session to Redis with TTL.
|
||||
|
||||
@@ -156,7 +154,7 @@ class RedisService:
|
||||
|
||||
Args:
|
||||
session_id: The session ID
|
||||
message: ConversationMessageDTO or ConversationEntryDTO
|
||||
message: ConversationEntry
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
@@ -285,9 +283,7 @@ class RedisService:
|
||||
# Save to Redis
|
||||
await self._cache_notification_session(updated_session)
|
||||
|
||||
async def _cache_notification_session(
|
||||
self, session: NotificationSession
|
||||
) -> bool:
|
||||
async def _cache_notification_session(self, session: NotificationSession) -> bool:
|
||||
"""Cache notification session in Redis."""
|
||||
if not self.redis:
|
||||
raise RuntimeError("Redis client not connected")
|
||||
|
||||
Reference in New Issue
Block a user