Update 25-sept

This commit is contained in:
PAVEL PALMA
2025-09-29 02:10:37 -06:00
parent 535a1d8ca0
commit 2ad649c321
17 changed files with 993 additions and 538 deletions

View File

@@ -0,0 +1,85 @@
/*
* 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

@@ -0,0 +1,23 @@
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

@@ -0,0 +1,17 @@
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

@@ -0,0 +1,24 @@
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

@@ -0,0 +1,44 @@
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

@@ -7,6 +7,7 @@ package com.example.mapper.messagefilter;
import com.example.dto.dialogflow.conversation.ConversationEntryDTO;
import com.example.dto.dialogflow.conversation.ConversationSessionDTO;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.time.Instant;
@@ -18,8 +19,11 @@ import java.util.stream.Collectors;
@Component
public class ConversationContextMapper {
private static final int MESSAGE_LIMIT = 60;
private static final int DAYS_LIMIT = 30;
@Value("${conversation.context.message.limit:60}")
private int messageLimit;
@Value("${conversation.context.days.limit:30}")
private int daysLimit;
public String toText(ConversationSessionDTO session) {
if (session == null || session.entries() == null || session.entries().isEmpty()) {
@@ -36,12 +40,12 @@ public class ConversationContextMapper {
return "";
}
Instant thirtyDaysAgo = Instant.now().minus(DAYS_LIMIT, ChronoUnit.DAYS);
Instant thirtyDaysAgo = Instant.now().minus(daysLimit, ChronoUnit.DAYS);
List<ConversationEntryDTO> recentEntries = session.entries().stream()
.filter(entry -> entry.timestamp().isAfter(thirtyDaysAgo))
.sorted(Comparator.comparing(ConversationEntryDTO::timestamp).reversed())
.limit(MESSAGE_LIMIT)
.limit(messageLimit)
.sorted(Comparator.comparing(ConversationEntryDTO::timestamp))
.collect(Collectors.toList());

View File

@@ -87,9 +87,6 @@ public class MessageEntryFilter {
String classificationPrompt = String.format(
this.promptTemplate,
CATEGORY_NOTIFICATION, CATEGORY_NOTIFICATION, CATEGORY_CONVERSATION, CATEGORY_CONVERSATION,
CATEGORY_CONVERSATION, CATEGORY_CONVERSATION, CATEGORY_CONVERSATION, CATEGORY_NOTIFICATION,
CATEGORY_NOTIFICATION, CATEGORY_CONVERSATION, CATEGORY_CONVERSATION,
conversationHistory,
interruptingNotification,
queryInputText
@@ -107,7 +104,7 @@ public class MessageEntryFilter {
classifierTopP
);
String resultCategory = switch (geminiResponse != null ? geminiResponse.trim().toUpperCase() : "") {
String resultCategory = switch (geminiResponse != null ? geminiResponse.strip().toUpperCase() : "") {
case CATEGORY_CONVERSATION -> {
logger.info("Classified as {}.", CATEGORY_CONVERSATION);
yield CATEGORY_CONVERSATION;

View File

@@ -2,6 +2,7 @@
* 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.service.notification.MemoryStoreNotificationService;
@@ -75,9 +76,10 @@ public class NotificationContextResolver {
public String resolveContext(String queryInputText, String notificationsJson, String conversationJson,
String metadata, String userId, String sessionId, String userPhoneNumber) {
logger.debug("resolveContext -> queryInputText: {}, notificationsJson: {}, conversationJson: {}, metadata: {}", queryInputText, notificationsJson, conversationJson, metadata);
logger.debug("resolveContext -> queryInputText: {}, notificationsJson: {}, conversationJson: {}, metadata: {}",
queryInputText, notificationsJson, conversationJson, metadata);
if (queryInputText == null || queryInputText.isBlank()) {
logger.warn("Query input text for context resolution is null or blank. Returning {}.", CATEGORY_DIALOGFLOW);
logger.warn("Query input text for context resolution is null or blank.", CATEGORY_DIALOGFLOW);
return CATEGORY_DIALOGFLOW;
}
@@ -107,15 +109,15 @@ public class NotificationContextResolver {
if (geminiResponse != null && !geminiResponse.isBlank()) {
if (geminiResponse.trim().equalsIgnoreCase(CATEGORY_DIALOGFLOW)) {
logger.info("Resolved to {}.", CATEGORY_DIALOGFLOW);
logger.debug("Resolved to {}. Input: '{}'", CATEGORY_DIALOGFLOW, queryInputText);
return CATEGORY_DIALOGFLOW;
} else {
logger.info("Resolved to a specific response.");
logger.debug("Resolved to a specific response. Input: '{}'", queryInputText);
return geminiResponse;
}
} else {
logger.warn("Gemini returned a null or blank response. Returning {}.",
CATEGORY_DIALOGFLOW);
logger.warn("Gemini returned a null or blank response",
queryInputText, CATEGORY_DIALOGFLOW);
return CATEGORY_DIALOGFLOW;
}
} catch (Exception e) {

View File

@@ -2,6 +2,7 @@
* 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.conversation;
import com.example.dto.dialogflow.base.DetectIntentRequestDTO;
@@ -10,7 +11,8 @@ import com.example.dto.dialogflow.conversation.ConversationContext;
import com.example.dto.dialogflow.conversation.ConversationEntryDTO;
import com.example.dto.dialogflow.conversation.ConversationSessionDTO;
import com.example.dto.dialogflow.conversation.ExternalConvRequestDTO;
import com.example.dto.dialogflow.conversation.QueryResultDTO;
import com.example.dto.dialogflow.conversation.QueryInputDTO;
import com.example.dto.dialogflow.notification.EventInputDTO;
import com.example.dto.dialogflow.notification.NotificationDTO;
import com.example.mapper.conversation.ExternalConvRequestMapper;
import com.example.mapper.messagefilter.ConversationContextMapper;
@@ -20,6 +22,7 @@ import com.example.service.base.MessageEntryFilter;
import com.example.service.base.NotificationContextResolver;
import com.example.service.notification.MemoryStoreNotificationService;
import com.example.service.quickreplies.QuickRepliesManagerService;
import com.example.service.llm.LlmResponseTunerService;
import com.example.util.SessionIdGenerator;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -32,8 +35,22 @@ import java.util.Collections;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.UUID;
import java.util.stream.Collectors;
/**
* Orchestrates the full lifecycle of a user conversation,(this is the core of
* the entire integration layer service), acting as the central point for all
* inbound requests.
* This service manages conversational state by integrating both an in-memory
* cache (for active sessions)
* and a durable database (for conversation history). It intelligently routes
* incoming messages
* to the appropriate handler, which can include a standard Dialogflow agent, a
* notification-specific flow, or a direct LLM-based response. The class also
* ensures data integrity and security by applying Data Loss Prevention (DLP)
* to all incoming user messages before they are processed.
*/
@Service
public class ConversationManagerService {
private static final Logger logger = LoggerFactory.getLogger(ConversationManagerService.class);
@@ -53,6 +70,7 @@ public class ConversationManagerService {
private final String dlpTemplateCompleteFlow;
private final NotificationContextResolver notificationContextResolver;
private final LlmResponseTunerService llmResponseTunerService;
public ConversationManagerService(
DialogflowClientService dialogflowServiceClient,
@@ -66,6 +84,7 @@ public class ConversationManagerService {
ConversationContextMapper conversationContextMapper,
DataLossPrevention dataLossPrevention,
NotificationContextResolver notificationContextResolver,
LlmResponseTunerService llmResponseTunerService,
@Value("${google.cloud.dlp.dlpTemplateCompleteFlow}") String dlpTemplateCompleteFlow) {
this.dialogflowServiceClient = dialogflowServiceClient;
this.firestoreConversationService = firestoreConversationService;
@@ -79,6 +98,7 @@ public class ConversationManagerService {
this.dataLossPrevention = dataLossPrevention;
this.dlpTemplateCompleteFlow = dlpTemplateCompleteFlow;
this.notificationContextResolver = notificationContextResolver;
this.llmResponseTunerService = llmResponseTunerService;
}
@@ -93,7 +113,6 @@ public class ConversationManagerService {
externalrequest.pantallaContexto());
return memoryStoreConversationService.getSessionByTelefono(externalrequest.user().telefono())
.flatMap(session -> {
if (session != null && session.pantallaContexto() != null
&& !session.pantallaContexto().isBlank()) {
logger.info(
@@ -117,14 +136,21 @@ public class ConversationManagerService {
"Failed to process external request due to mapping error: " + e.getMessage(), e));
}
final ConversationContext context;
try {
context = resolveAndValidateRequest(request);
} catch (IllegalArgumentException e) {
logger.error("Validation error for incoming request: {}", e.getMessage());
return Mono.error(e);
Map<String, Object> params = Optional.ofNullable(request.queryParams())
.map(queryParamsDTO -> queryParamsDTO.parameters())
.orElse(Collections.emptyMap());
Object telefonoObj = params.get("telefono");
if (!(telefonoObj instanceof String) || ((String) telefonoObj).isBlank()) {
logger.error("Critical error: parameter is missing, not a String, or blank after mapping.");
return Mono.error(new IllegalStateException("Internal error: parameter is invalid."));
}
String primaryPhoneNumber = (String) telefonoObj;
String resolvedUserId = params.get("usuario_id") instanceof String ? (String) params.get("usuario_id") : null;
String userMessageText = request.queryInput().text().text();
final ConversationContext context = new ConversationContext(resolvedUserId, null, userMessageText, primaryPhoneNumber);
return handleMessageClassification(context, request);
}
@@ -171,7 +197,7 @@ public class ConversationManagerService {
.error(new IllegalArgumentException("Phone number is required to manage conversation sessions."));
}
logger.info("Primary Check (MemoryStore): Looking up session for phone number");
logger.info("Primary Check (MemoryStore): Looking up session for phone number: {}", userPhoneNumber);
return memoryStoreConversationService.getSessionByTelefono(userPhoneNumber)
.flatMap(session -> handleMessageClassification(context, request, session))
.switchIfEmpty(Mono.defer(() -> {
@@ -274,7 +300,7 @@ public class ConversationManagerService {
finalSessionId, error.getMessage(), error))));
}
private Mono<DetectIntentResponseDTO> startNotificationConversation(ConversationContext context,
public Mono<DetectIntentResponseDTO> startNotificationConversation(ConversationContext context,
DetectIntentRequestDTO request, NotificationDTO notification) {
final String userId = context.userId();
final String userMessageText = context.userMessageText();
@@ -283,8 +309,8 @@ public class ConversationManagerService {
return memoryStoreNotificationService.getSessionByTelefono(userPhoneNumber)
.switchIfEmpty(Mono.defer(() -> {
String newSessionId = SessionIdGenerator.generateStandardSessionId();
logger.info("No existing notification session found for phone number. Creating new session: {}",
newSessionId);
logger.info("No existing notification session found for phone number {}. Creating new session: {}",
userPhoneNumber, newSessionId);
return Mono.just(ConversationSessionDTO.create(newSessionId, userId, userPhoneNumber));
}))
.flatMap(session -> {
@@ -301,6 +327,9 @@ public class ConversationManagerService {
userPhoneNumber);
if (!NotificationContextResolver.CATEGORY_DIALOGFLOW.equals(resolvedContext)) {
String uuid = UUID.randomUUID().toString();
llmResponseTunerService.setValue(uuid, resolvedContext).subscribe();
ConversationEntryDTO userEntry = ConversationEntryDTO.forUser(userMessageText,
notification.parametros());
ConversationEntryDTO llmEntry = ConversationEntryDTO.forLlmConversation(resolvedContext,
@@ -308,10 +337,21 @@ public class ConversationManagerService {
return persistNotificationTurn(userId, sessionId, userEntry, userPhoneNumber)
.then(persistNotificationTurn(userId, sessionId, llmEntry, userPhoneNumber))
.then(Mono.fromCallable(() -> {
QueryResultDTO queryResult = new QueryResultDTO(resolvedContext,
java.util.Collections.emptyMap());
return new DetectIntentResponseDTO(null, queryResult);
.then(Mono.defer(() -> {
EventInputDTO eventInput = new EventInputDTO("LLM_RESPONSE_PROCESSED");
QueryInputDTO queryInput = new QueryInputDTO(null, eventInput,
request.queryInput().languageCode());
DetectIntentRequestDTO newRequest = new DetectIntentRequestDTO(queryInput,
request.queryParams())
.withParameter("llm_reponse_uuid", uuid);
return dialogflowServiceClient.detectIntent(sessionId, newRequest)
.flatMap(response -> {
ConversationEntryDTO agentEntry = ConversationEntryDTO
.forAgent(response.queryResult());
return persistNotificationTurn(userId, sessionId, agentEntry,
userPhoneNumber)
.thenReturn(response);
});
}));
}
@@ -372,39 +412,4 @@ public class ConversationManagerService {
entry.type().name(), e.getMessage(), e));
}
private ConversationContext resolveAndValidateRequest(DetectIntentRequestDTO request) {
Map<String, Object> params = Optional.ofNullable(request.queryParams())
.map(queryParamsDTO -> queryParamsDTO.parameters())
.orElse(Collections.emptyMap());
String primaryPhoneNumber = null;
Object telefonoObj = params.get("telefono"); // Get from map
if (telefonoObj instanceof String) {
primaryPhoneNumber = (String) telefonoObj;
} else if (telefonoObj != null) {
logger.warn("Parameter 'telefono' in queryParams is not a String (type: {}). Expected String.",
telefonoObj.getClass().getName());
}
if (primaryPhoneNumber == null || primaryPhoneNumber.trim().isEmpty()) {
throw new IllegalArgumentException(
"Phone number (telefono) is required in query parameters for conversation management.");
}
String resolvedUserId = null;
Object userIdObj = params.get("usuario_id");
if (userIdObj instanceof String) {
resolvedUserId = (String) userIdObj;
} else if (userIdObj != null) {
logger.warn("Parameter 'userId' in query_params is not a String (type: {}). Expected String.",
userIdObj.getClass().getName());
}
if (resolvedUserId == null || resolvedUserId.trim().isEmpty()) {
resolvedUserId = "user_by_phone_" + primaryPhoneNumber.replaceAll("[^0-9]", "");
logger.warn("User ID not provided in query parameters. Using derived ID from phone number");
}
if (request.queryInput() == null || request.queryInput().text() == null ||
request.queryInput().text().text() == null || request.queryInput().text().text().trim().isEmpty()) {
throw new IllegalArgumentException("Dialogflow query input text is required.");
}
String userMessageText = request.queryInput().text().text();
return new ConversationContext(resolvedUserId, null, userMessageText, primaryPhoneNumber);
}
}

View File

@@ -21,6 +21,14 @@ import com.example.util.TextObfuscator;
import reactor.core.publisher.Mono;
/**
Implements a data loss prevention service by integrating with the
Google Cloud Data Loss Prevention (DLP) API. This service is responsible for
scanning a given text input to identify and obfuscate sensitive information based on
a specified DLP template. If the DLP API detects sensitive findings, the
original text is obfuscated to protect user data; otherwise, the original
text is returned.
*/
@Service
public class DataLossPreventionImpl implements DataLossPrevention {

View File

@@ -0,0 +1,13 @@
/*
* 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.llm;
import reactor.core.publisher.Mono;
public interface LlmResponseTunerService {
Mono<String> getValue(String key);
Mono<Void> setValue(String key, String value);
}

View File

@@ -0,0 +1,33 @@
/*
* 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.llm;
import java.time.Duration;
import org.springframework.data.redis.core.ReactiveRedisTemplate;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Mono;
@Service
public class LlmResponseTunerServiceImpl implements LlmResponseTunerService {
private final ReactiveRedisTemplate<String, String> reactiveStringRedisTemplate;
private final String llmPreResponseCollectionName = "llm-pre-response:";
private final Duration ttl = Duration.ofHours(1);
public LlmResponseTunerServiceImpl(ReactiveRedisTemplate<String, String> reactiveStringRedisTemplate) {
this.reactiveStringRedisTemplate = reactiveStringRedisTemplate;
}
@Override
public Mono<String> getValue(String key) {
return reactiveStringRedisTemplate.opsForValue().get(llmPreResponseCollectionName + key);
}
@Override
public Mono<Void> setValue(String key, String value) {
return reactiveStringRedisTemplate.opsForValue().set(llmPreResponseCollectionName + key, value, ttl).then();
}
}

View File

@@ -71,3 +71,8 @@ firestore.data.importer.enabled=false
# =========================================================
logging.level.root=INFO
logging.level.com.example=INFO
# =========================================================
# ConversationContext Configuration
# =========================================================
conversation.context.message.limit=${CONVERSATION_CONTEXT_MESSAGE_LIMIT}
conversation.context.days.limit=${CONVERSATION_CONTEXT_DAYS_LIMIT}

View File

@@ -1,54 +1,93 @@
You are an expert AI classification agent.
Your task is to analyze a user's final message after an external notification interrupts an ongoing conversation.
Hay un sistema de conversaciones entre un agente y un usuario. Durante
la conversación, una notificación puede entrar a la conversación de forma
abrupta, de tal forma que la siguiente interacción del usuario después
de la notificación puede corresponder a la conversación que estaba
sucediendo o puede ser un seguimiento a la notificación.
You will receive three pieces of information:
1. `CONVERSATION_HISTORY`: The dialogue between the agent and the user *before* the notification.
2. `INTERRUPTING_NOTIFICATION`: The specific alert that appeared.
3. `USER_FINAL_INPUT`: The user's message that you must classify.
Tu tarea es identificar si la siguiente interacción del usuario es un
seguimiento a la notificación o una continuación de la conversación.
Your goal is to determine if the `USER_FINAL_INPUT` is a reaction to the `INTERRUPTING_NOTIFICATION` or a continuation of the `CONVERSATION_HISTORY`.
Recibirás esta información:
**Classification Rules:**
- HISTORIAL_CONVERSACION: El diálogo entre el agente y el usuario antes
de la notificación.
- INTERRUPCION_NOTIFICACION: La notificación. Esta puede o no traer parámetros
los cuales refieren a detalles específicos de la notificación. Por ejemplo:
{ "vigencia": “12 de septiembre de 2025”, "credito_tipo" : "platinum" }
- INTERACCION_USUARIO: La siguiente interacción del usuario después de
la notificación.
* **`%s`**: Classify as `%s` if the `USER_FINAL_INPUT` is a direct question, comment, or reaction related to the `INTERRUPTING_NOTIFICATION`. The user has switched their focus to the notification.
* **`%s`**: Classify as `%s` if the `USER_FINAL_INPUT` ignores the notification and continues the topic from the `CONVERSATION_HISTORY`.
* **Ambiguity Rule**: If the input is ambiguous (e.g., "ok, thanks"), default to `%s`. It's safer to assume the user is concluding the original topic.
* **Acknowledgement Rule**: If the user briefly acknowledges the notification but immediately pivots back to the original conversation (e.g., "Okay thank you, but back to my question about loans..."), classify it as `%s`. The PRIMARY INTENT is to continue the original dialogue.
Reglas:
- Solo debes responder una palabra: NOTIFICATION o CONVERSATION. No agregues
o inventes otra palabra.
- Clasifica como NOTIFICATION si la siguiente interacción del usuario
es una clara respuesta o seguimiento a la notificación.
- Clasifica como CONVERSATION si la siguiente interacción del usuario
es un claro seguimiento al histórico de la conversación.
- Si la siguiente interacción del usuario es ambigua, clasifica
como CONVERSATION.
Your response must be a single word: `%s` or `%s`. Do not add any other text, punctuation, or explanations.
Ejemplos:
---
**Examples (Few-Shot Learning):**
Ejemplo 1:
HISTORIAL_CONVERSACION:
Agente: Claro, para un crédito de vehículo, las tasas actuales inician en el 1.2%% mensual.
Usuario: Entiendo, ¿y el plazo máximo de cuánto sería?
INTERRUPCION_NOTIFICACION:
Tu pago de la tarjeta de crédito por $1,500.00 ha sido procesado.
INTERACCION_USUARIO:
perfecto, cuando es la fecha de corte?
Clasificación: NOTIFICACION
**Example 1:**
`CONVERSATION_HISTORY`:
Agent: Claro, para un crédito de vehículo, las tasas actuales inician en el 1.2%% mensual.
User: Entiendo, ¿y el plazo máximo de cuánto sería?
`INTERRUPTING_NOTIFICATION`: Tu pago de la tarjeta de crédito por $1,500.00 ha sido procesado.
`USER_FINAL_INPUT`: ¡Perfecto! Justo de eso quería saber, ¿ese pago ya se ve reflejado en el cupo disponible?
Classification: %s
Ejemplo 2:
HISTORIAL_CONVERSACION:
Agente: No es necesario, puedes completar todo el proceso para abrir tu cuenta desde nuestra app.
Usuario: Ok
Agente: ¿Necesitas algo más?
INTERRUPCION_NOTIFICACION:
Tu estado de cuenta de Julio ya está disponible.
Parametros: {"fecha_corte": "30 de Agosto del 2025", "tipo_cuenta": "credito"}
INTERACCION_USUARIO:
que documentos necesito?
Clasificación: CONVERSACION
**Example 2:**
`CONVERSATION_HISTORY`:
Agent: No es necesario, puedes completar todo el proceso para abrir tu cuenta desde nuestra app.
User: Ok, suena fácil.
`INTERRUPTING_NOTIFICATION`: Tu estado de cuenta de Julio ya está disponible.
`USER_FINAL_INPUT`: Bueno, y qué documentos necesito tener a la mano para hacerlo en la app?
Classification: %s
Ejemplo 3:
HISTORIAL_CONVERSACION:
Agente: Ese fondo de inversión tiene un perfil de alto riesgo, pero históricamente ha dado un rendimiento superior al 15%% anual.
Usuario: ok, entiendo
INTERRUPCION_NOTIFICACION:
Alerta: Tu cuenta de ahorros tiene un saldo bajo de $50.00.
Parametros: {"fecha_retiro": "5 de septiembre del 2025", "tipo_cuenta": "ahorros"}
INTERACCION_USUARIO:
cuando fue el ultimo retiro?
Clasificación: NOTIFICACION
**Example 3:**
`CONVERSATION_HISTORY`:
Agent: Ese fondo de inversión tiene un perfil de alto riesgo, pero históricamente ha dado un rendimiento superior al 15%% anual.
User: Suena interesante...
`INTERRUPTING_NOTIFICATION`: Alerta: Tu cuenta de ahorros tiene un saldo bajo de $50.00.
`USER_FINAL_INPUT`: Umm, ok gracias cuando fue el ultimo retiro?. Pero volviendo al fondo, ¿cuál es la inversión mínima para entrar?
Classification: %s
---
Ejemplo 4:
HISTORIAL_CONVERSACION:
Usuario: Que es el CAT?
Agente: El CAT (Costo Anual Total) es un indicador financiero, expresado en un porcentaje anual, que refleja el costo total de un crédito, incluyendo no solo la tasa de interés, sino también todas las comisiones, gastos y otros cobros que genera.
INTERRUPCION_NOTIFICACION:
Alerta: Se realizó un retiro en efectivo por $100.
INTERACCION_USUARIO:
y este se aplica solo si dejo de pagar?
Clasificación: CONVERSACION
**Task:**
Ejemplo 5:
HISTORIAL_CONVERSACION:
Usuario: Cual es la tasa de hipoteca que manejan?
Agente: La tasa de una hipoteca depende tanto de factores económicos generales (inflación, tasas de referencia del banco central) como de factores individuales del solicitante (historial crediticio, monto del pago inicial, ingresos, endeudamiento, etc.)
INTERRUPCION_NOTIFICACION:
Hola, [Alias]: Pasó algo con la captura de tu INE y no se completó tu solicitud de tarjeta de crédito con folio 3421.
Parametros: {“solicitud_tarjeta_credito_vigencia”: “12 de septiembre de 2025”, “solicitud_tarjeta_credito_error”: “Error con el formato de la captura”, “solicitud_tarjeta_credito_tipo” : “platinum” }
INTERACCION_USUARIO:
cual fue el error?
Clasificación: NOTIFICACION
`CONVERSATION_HISTORY`:
Tarea:
HISTORIAL_CONVERSACION:
%s
`INTERRUPTING_NOTIFICATION`: %s
`USER_FINAL_INPUT`: %s
Classification:
INTERRUPCION_NOTIFICACION:
%s
INTERACCION_USUARIO:
%s
Clasificación:

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.
*/
package com.example.service.conversation;
import com.example.dto.dialogflow.base.DetectIntentRequestDTO;
import com.example.dto.dialogflow.base.DetectIntentResponseDTO;
import com.example.dto.dialogflow.conversation.ConversationContext;
import com.example.dto.dialogflow.conversation.ConversationEntryDTO;
import com.example.dto.dialogflow.conversation.ConversationSessionDTO;
import com.example.dto.dialogflow.notification.NotificationDTO;
import com.example.mapper.conversation.ExternalConvRequestMapper;
import com.example.mapper.messagefilter.ConversationContextMapper;
import com.example.mapper.messagefilter.NotificationContextMapper;
import com.example.service.base.DialogflowClientService;
import com.example.service.base.MessageEntryFilter;
import com.example.service.base.NotificationContextResolver;
import com.example.service.llm.LlmResponseTunerService;
import com.example.service.notification.MemoryStoreNotificationService;
import com.example.service.quickreplies.QuickRepliesManagerService;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
import java.time.Instant;
import java.util.Collections;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
public class ConversationManagerServiceTest {
@Mock
private ExternalConvRequestMapper externalRequestToDialogflowMapper;
@Mock
private DialogflowClientService dialogflowServiceClient;
@Mock
private FirestoreConversationService firestoreConversationService;
@Mock
private MemoryStoreConversationService memoryStoreConversationService;
@Mock
private QuickRepliesManagerService quickRepliesManagerService;
@Mock
private MessageEntryFilter messageEntryFilter;
@Mock
private MemoryStoreNotificationService memoryStoreNotificationService;
@Mock
private NotificationContextMapper notificationContextMapper;
@Mock
private ConversationContextMapper conversationContextMapper;
@Mock
private DataLossPrevention dataLossPrevention;
@Mock
private NotificationContextResolver notificationContextResolver;
@Mock
private LlmResponseTunerService llmResponseTunerService;
@InjectMocks
private ConversationManagerService conversationManagerService;
@Test
void startNotificationConversation_shouldSaveResolvedContextAndReturnIt() {
// Given
String userId = "test-user";
String userPhoneNumber = "1234567890";
String userMessageText = "test message";
String sessionId = "test-session";
String resolvedContext = "resolved context";
ConversationContext context = new ConversationContext(userId, null, userMessageText, userPhoneNumber);
DetectIntentRequestDTO request = new DetectIntentRequestDTO(null, null);
NotificationDTO notification = new NotificationDTO("1", "1234567890", Instant.now(), "test text", "test_event", "es", Collections.emptyMap(), "active");
ConversationSessionDTO session = ConversationSessionDTO.create(sessionId, userId, userPhoneNumber);
when(memoryStoreNotificationService.getSessionByTelefono(userPhoneNumber)).thenReturn(Mono.just(session));
when(conversationContextMapper.toTextWithLimits(session)).thenReturn("history");
when(notificationContextMapper.toText(notification)).thenReturn("notification text");
when(notificationContextResolver.resolveContext(anyString(), anyString(), anyString(), anyString(), anyString(), anyString(), anyString()))
.thenReturn(resolvedContext);
when(llmResponseTunerService.setValue(anyString(), anyString())).thenReturn(Mono.empty());
when(memoryStoreNotificationService.saveEntry(anyString(), anyString(), any(ConversationEntryDTO.class), anyString())).thenReturn(Mono.empty());
// When
Mono<DetectIntentResponseDTO> result = conversationManagerService.startNotificationConversation(context, request, notification);
// Then
StepVerifier.create(result)
.expectNextMatches(response -> response.queryResult().responseText().equals(resolvedContext))
.verifyComplete();
}
}

View File

@@ -0,0 +1,57 @@
package com.example.service.llm;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.data.redis.core.ReactiveRedisTemplate;
import org.springframework.data.redis.core.ReactiveValueOperations;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
public class LlmResponseTunerServiceImplTest {
@Mock
private ReactiveRedisTemplate<String, String> reactiveStringRedisTemplate;
@Mock
private ReactiveValueOperations<String, String> reactiveValueOperations;
@InjectMocks
private LlmResponseTunerServiceImpl llmResponseTunerService;
private final String llmPreResponseCollectionName = "llm-pre-response:";
@BeforeEach
void setUp() {
when(reactiveStringRedisTemplate.opsForValue()).thenReturn(reactiveValueOperations);
}
@Test
void getValue_shouldReturnValueFromRedis() {
String key = "test_key";
String expectedValue = "test_value";
when(reactiveValueOperations.get(llmPreResponseCollectionName + key)).thenReturn(Mono.just(expectedValue));
StepVerifier.create(llmResponseTunerService.getValue(key))
.expectNext(expectedValue)
.verifyComplete();
}
@Test
void setValue_shouldSetValueInRedis() {
String key = "test_key";
String value = "test_value";
when(reactiveValueOperations.set(llmPreResponseCollectionName + key, value)).thenReturn(Mono.just(true));
StepVerifier.create(llmResponseTunerService.setValue(key, value))
.verifyComplete();
}
}