diff --git a/src/main/java/com/pandol365/dewey/api/controller/McpController.java b/src/main/java/com/pandol365/dewey/api/controller/McpController.java index 007cbd1..692c8cd 100644 --- a/src/main/java/com/pandol365/dewey/api/controller/McpController.java +++ b/src/main/java/com/pandol365/dewey/api/controller/McpController.java @@ -5,12 +5,14 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.pandol365.dewey.api.dto.request.McpJsonRpcRequest; import com.pandol365.dewey.api.dto.response.McpJsonRpcResponse; import com.pandol365.dewey.api.service.McpService; +import com.pandol365.dewey.domain.conversation.service.ConversationService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import java.util.HashMap; +import java.util.List; import java.util.Map; /** @@ -26,6 +28,7 @@ public class McpController { private final McpService mcpService; private final ObjectMapper objectMapper; + private final ConversationService conversationService; /** * 헬스 체크 엔드포인트 (GET 요청 처리) @@ -105,6 +108,9 @@ public class McpController { request.getMethod(), request.getId(), result); } + // 대화 자동 저장 (initialized 알림 제외) + saveConversation(request, response); + return ResponseEntity.ok(response); } catch (Exception e) { @@ -132,6 +138,9 @@ public class McpController { request.getMethod(), request.getId(), error); } + // 대화 자동 저장 (에러 응답도 저장) + saveConversation(request, response); + return ResponseEntity.ok(response); } } @@ -185,4 +194,252 @@ public class McpController { default -> throw new IllegalArgumentException("Unknown method: " + method); }; } + + /** + * 대화를 Redis에 자동 저장 + * 사용자 요청과 AI 응답을 하나의 row로 저장 + */ + private void saveConversation(McpJsonRpcRequest request, McpJsonRpcResponse response) { + try { + // initialized 알림은 저장하지 않음 + if ("initialized".equals(request.getMethod()) && request.getId() == null) { + return; + } + + // 사용자 메시지 추출 + String userMessage = extractUserMessage(request); + + // AI 응답 추출 + String aiResponse = extractAiResponse(response); + + // 사용자 ID 추출 + String userId = extractUserId(request); + + // 메타데이터 생성 + String metadata = createMetadata(request, response); + + // 대화 저장 + conversationService.saveConversation(userId, userMessage, aiResponse, metadata); + + } catch (Exception e) { + log.warn("대화 저장 실패: {}", e.getMessage()); + } + } + + /** + * 요청에서 사용자 메시지 추출 + * 실제 사용자가 입력한 내용을 추출 + */ + @SuppressWarnings("unchecked") + private String extractUserMessage(McpJsonRpcRequest request) { + try { + String method = request.getMethod(); + + // tools/call의 경우 실제 사용자 입력 내용 추출 + if ("tools/call".equals(method) && request.getParams() != null) { + Object args = request.getParams().getArguments(); + if (args instanceof Map) { + Map callArgs = (Map) args; + Object toolArgs = callArgs.get("arguments"); + + if (toolArgs instanceof Map) { + Map toolArguments = (Map) toolArgs; + String toolName = callArgs.get("name") != null ? callArgs.get("name").toString() : null; + + // 도구별로 실제 사용자 입력 추출 + if ("store_memory".equals(toolName)) { + String memoryText = toolArguments.get("memory_text") != null ? + toolArguments.get("memory_text").toString() : null; + if (memoryText != null && !memoryText.isEmpty()) { + return memoryText; + } + } else if ("search_memory".equals(toolName)) { + String query = toolArguments.get("query") != null ? + toolArguments.get("query").toString() : null; + if (query != null && !query.isEmpty()) { + return "메모리 검색: " + query; + } + } else if ("retrieve_memory".equals(toolName)) { + String userId = toolArguments.get("user_id") != null ? + toolArguments.get("user_id").toString() : null; + Integer limit = toolArguments.get("limit") != null ? + ((Number) toolArguments.get("limit")).intValue() : null; + return "메모리 조회 요청" + (userId != null ? " (사용자: " + userId + ")" : "") + + (limit != null ? " (개수: " + limit + ")" : ""); + } else if ("retrieve_conversation".equals(toolName)) { + String userId = toolArguments.get("user_id") != null ? + toolArguments.get("user_id").toString() : null; + Integer limit = toolArguments.get("limit") != null ? + ((Number) toolArguments.get("limit")).intValue() : null; + return "대화 조회 요청" + (userId != null ? " (사용자: " + userId + ")" : "") + + (limit != null ? " (개수: " + limit + ")" : ""); + } + } + } + } + + // resources/read의 경우 URI 추출 + if ("resources/read".equals(method) && request.getParams() != null) { + String uri = request.getParams().getUri(); + if (uri != null && !uri.isEmpty()) { + return "리소스 읽기 요청: " + uri; + } + // arguments에서 uri 추출 시도 + if (request.getParams().getArguments() instanceof Map) { + Map args = (Map) request.getParams().getArguments(); + uri = args.get("uri") != null ? args.get("uri").toString() : null; + if (uri != null && !uri.isEmpty()) { + return "리소스 읽기 요청: " + uri; + } + } + } + + // prompts/get의 경우 프롬프트 이름 추출 + if ("prompts/get".equals(method) && request.getParams() != null) { + if (request.getParams().getArguments() instanceof Map) { + Map args = (Map) request.getParams().getArguments(); + String promptName = args.get("name") != null ? args.get("name").toString() : null; + if (promptName != null && !promptName.isEmpty()) { + return "프롬프트 요청: " + promptName; + } + } + } + + // 기본값: 메서드 이름 반환 + return method != null ? method : "Unknown request"; + + } catch (Exception e) { + log.warn("사용자 메시지 추출 실패: {}", e.getMessage()); + return request.getMethod() != null ? request.getMethod() : "Unknown request"; + } + } + + /** + * 응답에서 AI 응답 추출 + * 실제 AI가 반환한 의미있는 내용 추출 + */ + @SuppressWarnings("unchecked") + private String extractAiResponse(McpJsonRpcResponse response) { + try { + if (response.getError() != null) { + return "오류: " + response.getError().getMessage(); + } + + if (response.getResult() == null) { + return "응답 없음"; + } + + // result가 Map인 경우 (대부분의 경우) + if (response.getResult() instanceof Map) { + Map result = (Map) response.getResult(); + + // tools/call 응답인 경우 content 필드 추출 + if (result.containsKey("content") && result.get("content") instanceof String) { + String content = (String) result.get("content"); + Boolean isError = result.get("isError") instanceof Boolean ? + (Boolean) result.get("isError") : null; + + if (isError != null && isError) { + return "오류: " + content; + } + return content; + } + + // resources/read 응답인 경우 contents 추출 + if (result.containsKey("contents") && result.get("contents") instanceof List) { + List contents = (List) result.get("contents"); + if (!contents.isEmpty() && contents.get(0) instanceof Map) { + Map firstContent = (Map) contents.get(0); + String text = firstContent.get("text") != null ? + firstContent.get("text").toString() : null; + if (text != null && !text.isEmpty()) { + return text; + } + } + } + + // resources/list 응답인 경우 리소스 목록 요약 + if (result.containsKey("resources") && result.get("resources") instanceof List) { + List resources = (List) result.get("resources"); + return resources.size() + "개의 리소스를 찾았습니다"; + } + + // tools/list 응답인 경우 도구 목록 요약 + if (result.containsKey("tools") && result.get("tools") instanceof List) { + List tools = (List) result.get("tools"); + return tools.size() + "개의 도구를 사용할 수 있습니다"; + } + + // prompts/list 응답인 경우 프롬프트 목록 요약 + if (result.containsKey("prompts") && result.get("prompts") instanceof List) { + List prompts = (List) result.get("prompts"); + return prompts.size() + "개의 프롬프트를 사용할 수 있습니다"; + } + + // prompts/get 응답인 경우 메시지 추출 + if (result.containsKey("messages") && result.get("messages") instanceof List) { + List messages = (List) result.get("messages"); + if (!messages.isEmpty() && messages.get(0) instanceof Map) { + Map firstMessage = (Map) messages.get(0); + String content = firstMessage.get("content") != null ? + firstMessage.get("content").toString() : null; + if (content != null && !content.isEmpty()) { + return content; + } + } + } + } + + // 기본값: result를 JSON으로 변환 (너무 길면 요약) + String jsonResult = objectMapper.writeValueAsString(response.getResult()); + if (jsonResult.length() > 500) { + return jsonResult.substring(0, 500) + "..."; + } + return jsonResult; + + } catch (Exception e) { + log.warn("AI 응답 추출 실패: {}", e.getMessage()); + return "응답 파싱 실패"; + } + } + + /** + * 사용자 ID 추출 + */ + @SuppressWarnings("unchecked") + private String extractUserId(McpJsonRpcRequest request) { + // 요청 파라미터에서 user_id 추출 시도 + if (request.getParams() != null && request.getParams().getArguments() instanceof Map) { + Map args = (Map) request.getParams().getArguments(); + if (args != null && args.get("user_id") != null) { + return args.get("user_id").toString(); + } + } + + // 기본값 반환 + return "anonymous"; + } + + /** + * 메타데이터 생성 + */ + private String createMetadata(McpJsonRpcRequest request, McpJsonRpcResponse response) { + try { + Map metadata = new HashMap<>(); + metadata.put("method", request.getMethod()); + metadata.put("requestId", request.getId()); + metadata.put("timestamp", System.currentTimeMillis()); + + if (response.getError() != null) { + metadata.put("hasError", true); + metadata.put("errorCode", response.getError().getCode()); + } else { + metadata.put("hasError", false); + } + + return objectMapper.writeValueAsString(metadata); + } catch (Exception e) { + return "{}"; + } + } } diff --git a/src/main/java/com/pandol365/dewey/api/service/impl/McpServiceImpl.java b/src/main/java/com/pandol365/dewey/api/service/impl/McpServiceImpl.java index e13e38a..b1cdfb8 100644 --- a/src/main/java/com/pandol365/dewey/api/service/impl/McpServiceImpl.java +++ b/src/main/java/com/pandol365/dewey/api/service/impl/McpServiceImpl.java @@ -3,6 +3,8 @@ package com.pandol365.dewey.api.service.impl; import com.pandol365.dewey.api.dto.request.McpJsonRpcRequest; import com.pandol365.dewey.api.dto.response.*; import com.pandol365.dewey.api.service.McpService; +import com.pandol365.dewey.domain.conversation.model.Conversation; +import com.pandol365.dewey.domain.conversation.service.ConversationService; import com.pandol365.dewey.domain.memory.service.MemoryService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -24,6 +26,7 @@ public class McpServiceImpl implements McpService { private static final String SERVER_VERSION = "0.0.1-SNAPSHOT"; private final MemoryService memoryService; + private final ConversationService conversationService; @Override public McpInitializeResponse initialize(McpJsonRpcRequest.McpParams params) { @@ -77,6 +80,7 @@ public class McpServiceImpl implements McpService { tools.add(createMemoryStoreTool()); tools.add(createMemoryRetrieveTool()); tools.add(createMemorySearchTool()); + tools.add(createConversationRetrieveTool()); return McpToolResponse.ToolListResponse.builder() .tools(tools) @@ -140,6 +144,20 @@ public class McpServiceImpl implements McpService { .metadata(metadata) .build(); } + case "retrieve_conversation" -> { + String userId = (String) args.get("user_id"); + Integer limit = args.get("limit") != null ? + ((Number) args.get("limit")).intValue() : 10; + + List conversations = conversationService.getRecentConversations(userId, limit); + + return McpToolCallResponse.builder() + .isError(false) + .content("Retrieved " + conversations.size() + " conversations") + .parts(Collections.emptyList()) + .metadata(metadata) + .build(); + } default -> { return McpToolCallResponse.builder() .isError(true) @@ -286,6 +304,22 @@ public class McpServiceImpl implements McpService { .build(); } + private McpToolResponse createConversationRetrieveTool() { + Map inputSchema = new HashMap<>(); + inputSchema.put("type", "object"); + inputSchema.put("properties", Map.of( + "user_id", Map.of("type", "string", "description", "사용자 ID"), + "limit", Map.of("type", "integer", "description", "조회할 대화 개수", "default", 10) + )); + inputSchema.put("required", Arrays.asList("user_id")); + + return McpToolResponse.builder() + .name("retrieve_conversation") + .description("사용자와 AI의 최근 대화를 조회합니다") + .inputSchema(inputSchema) + .build(); + } + // 프롬프트 생성 헬퍼 메서드들 private McpPromptResponse createMemorySummaryPrompt() { List arguments = new ArrayList<>(); diff --git a/src/main/java/com/pandol365/dewey/config/RedisConfig.java b/src/main/java/com/pandol365/dewey/config/RedisConfig.java new file mode 100644 index 0000000..8b3780b --- /dev/null +++ b/src/main/java/com/pandol365/dewey/config/RedisConfig.java @@ -0,0 +1,40 @@ +package com.pandol365.dewey.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +/** + * Redis 설정 클래스 + */ +@Configuration +public class RedisConfig { + + @Bean + public RedisTemplate redisTemplate(RedisConnectionFactory connectionFactory) { + RedisTemplate template = new RedisTemplate<>(); + template.setConnectionFactory(connectionFactory); + + // Key는 String으로 직렬화 + template.setKeySerializer(new StringRedisSerializer()); + template.setHashKeySerializer(new StringRedisSerializer()); + + // Value는 JSON으로 직렬화 (JSR310 모듈 포함) + ObjectMapper objectMapper = new ObjectMapper(); + objectMapper.registerModule(new JavaTimeModule()); + objectMapper.disable(com.fasterxml.jackson.databind.SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + + GenericJackson2JsonRedisSerializer jsonSerializer = new GenericJackson2JsonRedisSerializer(objectMapper); + template.setValueSerializer(jsonSerializer); + template.setHashValueSerializer(jsonSerializer); + + template.afterPropertiesSet(); + return template; + } +} + diff --git a/src/main/java/com/pandol365/dewey/domain/conversation/model/Conversation.java b/src/main/java/com/pandol365/dewey/domain/conversation/model/Conversation.java new file mode 100644 index 0000000..8e18ccb --- /dev/null +++ b/src/main/java/com/pandol365/dewey/domain/conversation/model/Conversation.java @@ -0,0 +1,60 @@ +package com.pandol365.dewey.domain.conversation.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * 대화 모델 (Model) + * 사용자와 AI의 대화를 하나의 row로 저장 + * Redis에 저장되는 대화 데이터 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class Conversation { + + /** + * 대화 ID (Redis key로 사용) + * 형식: conversation:{userId}:{timestamp}:{uuid} + */ + private String id; + + /** + * 사용자 ID + */ + private String userId; + + /** + * 사용자 메시지 (질문/요청) + */ + private String userMessage; + + /** + * AI 응답 (답변) + */ + private String aiResponse; + + /** + * 대화 생성 시간 + */ + private LocalDateTime createdAt; + + /** + * 대화 만료 시간 (TTL 기반) + */ + private LocalDateTime expiresAt; + + /** + * 추가 메타데이터 (JSON 형식) + * 예: method, tool, importance 등 + */ + private String metadata; +} + + + diff --git a/src/main/java/com/pandol365/dewey/domain/conversation/service/ConversationService.java b/src/main/java/com/pandol365/dewey/domain/conversation/service/ConversationService.java new file mode 100644 index 0000000..3561e96 --- /dev/null +++ b/src/main/java/com/pandol365/dewey/domain/conversation/service/ConversationService.java @@ -0,0 +1,50 @@ +package com.pandol365.dewey.domain.conversation.service; + +import com.pandol365.dewey.domain.conversation.model.Conversation; + +import java.util.List; + +/** + * 대화 도메인 비즈니스 로직 인터페이스 (Service) + */ +public interface ConversationService { + + /** + * 대화를 Redis에 저장 + * 사용자 메시지와 AI 응답을 하나의 row로 저장 + * + * @param userId 사용자 ID + * @param userMessage 사용자 메시지 + * @param aiResponse AI 응답 + * @param metadata 추가 메타데이터 (JSON 형식, 선택) + * @return 저장된 대화 객체 + */ + Conversation saveConversation(String userId, String userMessage, String aiResponse, String metadata); + + /** + * 사용자의 최근 대화 목록 조회 + * + * @param userId 사용자 ID + * @param limit 조회할 대화 개수 + * @return 대화 목록 + */ + List getRecentConversations(String userId, Integer limit); + + /** + * 특정 사용자의 모든 대화 조회 + * + * @param userId 사용자 ID + * @return 대화 목록 + */ + List getAllConversationsByUserId(String userId); + + /** + * 대화 삭제 + * + * @param conversationId 대화 ID + */ + void deleteConversation(String conversationId); +} + + + diff --git a/src/main/java/com/pandol365/dewey/domain/conversation/service/impl/ConversationServiceImpl.java b/src/main/java/com/pandol365/dewey/domain/conversation/service/impl/ConversationServiceImpl.java new file mode 100644 index 0000000..597bf8f --- /dev/null +++ b/src/main/java/com/pandol365/dewey/domain/conversation/service/impl/ConversationServiceImpl.java @@ -0,0 +1,148 @@ +package com.pandol365.dewey.domain.conversation.service.impl; + +import com.pandol365.dewey.domain.conversation.model.Conversation; +import com.pandol365.dewey.domain.conversation.service.ConversationService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.UUID; + +/** + * 대화 도메인 비즈니스 로직 구현체 (Service) + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class ConversationServiceImpl implements ConversationService { + + private static final String CONVERSATION_KEY_PREFIX = "conversation:"; + private static final String USER_CONVERSATIONS_KEY_PREFIX = "user:conversations:"; + private static final int DEFAULT_TTL_DAYS = 7; // 기본 TTL 7일 + + private final RedisTemplate redisTemplate; + + @Override + public Conversation saveConversation(String userId, String userMessage, String aiResponse, String metadata) { + log.info("대화 저장: userId={}, userMessageLength={}, aiResponseLength={}", + userId, + userMessage != null ? userMessage.length() : 0, + aiResponse != null ? aiResponse.length() : 0); + + // 대화 ID 생성: conversation:{userId}:{timestamp}:{uuid} + String timestamp = String.valueOf(System.currentTimeMillis()); + String uuid = UUID.randomUUID().toString().substring(0, 8); + String conversationId = CONVERSATION_KEY_PREFIX + userId + ":" + timestamp + ":" + uuid; + + // 대화 객체 생성 + Conversation conversation = Conversation.builder() + .id(conversationId) + .userId(userId) + .userMessage(userMessage) + .aiResponse(aiResponse) + .createdAt(LocalDateTime.now()) + .expiresAt(LocalDateTime.now().plusDays(DEFAULT_TTL_DAYS)) + .metadata(metadata) + .build(); + + // Redis에 저장 (TTL 설정) + redisTemplate.opsForValue().set(conversationId, conversation, Duration.ofDays(DEFAULT_TTL_DAYS)); + + // 사용자별 대화 목록에 추가 (Sorted Set 사용하여 시간순 정렬) + String userConversationsKey = USER_CONVERSATIONS_KEY_PREFIX + userId; + redisTemplate.opsForZSet().add(userConversationsKey, conversationId, (double) System.currentTimeMillis()); + redisTemplate.expire(userConversationsKey, Duration.ofDays(DEFAULT_TTL_DAYS)); + + log.info("대화 저장 완료: conversationId={}", conversationId); + return conversation; + } + + @Override + public List getRecentConversations(String userId, Integer limit) { + log.info("최근 대화 조회: userId={}, limit={}", userId, limit); + + String userConversationsKey = USER_CONVERSATIONS_KEY_PREFIX + userId; + + // Sorted Set에서 최신 대화 ID 목록 조회 (내림차순) + int limitValue = limit != null && limit > 0 ? limit : 10; + Set conversationIds = redisTemplate.opsForZSet() + .reverseRange(userConversationsKey, 0, limitValue - 1); + + if (conversationIds == null || conversationIds.isEmpty()) { + return List.of(); + } + + // 각 대화 ID로 실제 대화 데이터 조회 + List conversations = new ArrayList<>(); + for (Object conversationIdObj : conversationIds) { + if (conversationIdObj instanceof String conversationId) { + Object conversationObj = redisTemplate.opsForValue().get(conversationId); + if (conversationObj instanceof Conversation conversation) { + conversations.add(conversation); + } + } + } + + log.info("최근 대화 조회 완료: count={}", conversations.size()); + return conversations; + } + + @Override + public List getAllConversationsByUserId(String userId) { + log.info("사용자 전체 대화 조회: userId={}", userId); + + String userConversationsKey = USER_CONVERSATIONS_KEY_PREFIX + userId; + + // Sorted Set에서 모든 대화 ID 조회 + Set conversationIds = redisTemplate.opsForZSet() + .reverseRange(userConversationsKey, 0, -1); + + if (conversationIds == null || conversationIds.isEmpty()) { + return List.of(); + } + + // 각 대화 ID로 실제 대화 데이터 조회 + List conversations = new ArrayList<>(); + for (Object conversationIdObj : conversationIds) { + if (conversationIdObj instanceof String conversationId) { + Object conversationObj = redisTemplate.opsForValue().get(conversationId); + if (conversationObj instanceof Conversation conversation) { + conversations.add(conversation); + } + } + } + + log.info("사용자 전체 대화 조회 완료: count={}", conversations.size()); + return conversations; + } + + @Override + public void deleteConversation(String conversationId) { + log.info("대화 삭제: conversationId={}", conversationId); + + // 대화 데이터 조회하여 userId 확인 + Object conversationObj = redisTemplate.opsForValue().get(conversationId); + if (!(conversationObj instanceof Conversation)) { + log.warn("삭제할 대화를 찾을 수 없음: conversationId={}", conversationId); + return; + } + + Conversation conversation = (Conversation) conversationObj; + + // 대화 데이터 삭제 + redisTemplate.delete(conversationId); + + // 사용자별 대화 목록에서도 제거 + String userConversationsKey = USER_CONVERSATIONS_KEY_PREFIX + conversation.getUserId(); + redisTemplate.opsForZSet().remove(userConversationsKey, conversationId); + + log.info("대화 삭제 완료: conversationId={}", conversationId); + } +} +