동작에 대한 작업 백업용 커밋

작동x
This commit is contained in:
mskim 2025-12-11 13:23:54 +09:00
parent ab6bca3e53
commit ec0c8c55ba
6 changed files with 589 additions and 0 deletions

View File

@ -5,12 +5,14 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import com.pandol365.dewey.api.dto.request.McpJsonRpcRequest; import com.pandol365.dewey.api.dto.request.McpJsonRpcRequest;
import com.pandol365.dewey.api.dto.response.McpJsonRpcResponse; import com.pandol365.dewey.api.dto.response.McpJsonRpcResponse;
import com.pandol365.dewey.api.service.McpService; import com.pandol365.dewey.api.service.McpService;
import com.pandol365.dewey.domain.conversation.service.ConversationService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.util.HashMap; import java.util.HashMap;
import java.util.List;
import java.util.Map; import java.util.Map;
/** /**
@ -26,6 +28,7 @@ public class McpController {
private final McpService mcpService; private final McpService mcpService;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final ConversationService conversationService;
/** /**
* 헬스 체크 엔드포인트 (GET 요청 처리) * 헬스 체크 엔드포인트 (GET 요청 처리)
@ -105,6 +108,9 @@ public class McpController {
request.getMethod(), request.getId(), result); request.getMethod(), request.getId(), result);
} }
// 대화 자동 저장 (initialized 알림 제외)
saveConversation(request, response);
return ResponseEntity.ok(response); return ResponseEntity.ok(response);
} catch (Exception e) { } catch (Exception e) {
@ -132,6 +138,9 @@ public class McpController {
request.getMethod(), request.getId(), error); request.getMethod(), request.getId(), error);
} }
// 대화 자동 저장 (에러 응답도 저장)
saveConversation(request, response);
return ResponseEntity.ok(response); return ResponseEntity.ok(response);
} }
} }
@ -185,4 +194,252 @@ public class McpController {
default -> throw new IllegalArgumentException("Unknown method: " + method); 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<String, Object> callArgs = (Map<String, Object>) args;
Object toolArgs = callArgs.get("arguments");
if (toolArgs instanceof Map) {
Map<String, Object> toolArguments = (Map<String, Object>) 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<String, Object> args = (Map<String, Object>) 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<String, Object> args = (Map<String, Object>) 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<String, Object> result = (Map<String, Object>) 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<Object> contents = (List<Object>) result.get("contents");
if (!contents.isEmpty() && contents.get(0) instanceof Map) {
Map<String, Object> firstContent = (Map<String, Object>) 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<Object> resources = (List<Object>) result.get("resources");
return resources.size() + "개의 리소스를 찾았습니다";
}
// tools/list 응답인 경우 도구 목록 요약
if (result.containsKey("tools") && result.get("tools") instanceof List) {
List<Object> tools = (List<Object>) result.get("tools");
return tools.size() + "개의 도구를 사용할 수 있습니다";
}
// prompts/list 응답인 경우 프롬프트 목록 요약
if (result.containsKey("prompts") && result.get("prompts") instanceof List) {
List<Object> prompts = (List<Object>) result.get("prompts");
return prompts.size() + "개의 프롬프트를 사용할 수 있습니다";
}
// prompts/get 응답인 경우 메시지 추출
if (result.containsKey("messages") && result.get("messages") instanceof List) {
List<Object> messages = (List<Object>) result.get("messages");
if (!messages.isEmpty() && messages.get(0) instanceof Map) {
Map<String, Object> firstMessage = (Map<String, Object>) 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<String, Object> args = (Map<String, Object>) 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<String, Object> 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 "{}";
}
}
} }

View File

@ -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.request.McpJsonRpcRequest;
import com.pandol365.dewey.api.dto.response.*; import com.pandol365.dewey.api.dto.response.*;
import com.pandol365.dewey.api.service.McpService; 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 com.pandol365.dewey.domain.memory.service.MemoryService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; 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 static final String SERVER_VERSION = "0.0.1-SNAPSHOT";
private final MemoryService memoryService; private final MemoryService memoryService;
private final ConversationService conversationService;
@Override @Override
public McpInitializeResponse initialize(McpJsonRpcRequest.McpParams params) { public McpInitializeResponse initialize(McpJsonRpcRequest.McpParams params) {
@ -77,6 +80,7 @@ public class McpServiceImpl implements McpService {
tools.add(createMemoryStoreTool()); tools.add(createMemoryStoreTool());
tools.add(createMemoryRetrieveTool()); tools.add(createMemoryRetrieveTool());
tools.add(createMemorySearchTool()); tools.add(createMemorySearchTool());
tools.add(createConversationRetrieveTool());
return McpToolResponse.ToolListResponse.builder() return McpToolResponse.ToolListResponse.builder()
.tools(tools) .tools(tools)
@ -140,6 +144,20 @@ public class McpServiceImpl implements McpService {
.metadata(metadata) .metadata(metadata)
.build(); .build();
} }
case "retrieve_conversation" -> {
String userId = (String) args.get("user_id");
Integer limit = args.get("limit") != null ?
((Number) args.get("limit")).intValue() : 10;
List<Conversation> conversations = conversationService.getRecentConversations(userId, limit);
return McpToolCallResponse.builder()
.isError(false)
.content("Retrieved " + conversations.size() + " conversations")
.parts(Collections.emptyList())
.metadata(metadata)
.build();
}
default -> { default -> {
return McpToolCallResponse.builder() return McpToolCallResponse.builder()
.isError(true) .isError(true)
@ -286,6 +304,22 @@ public class McpServiceImpl implements McpService {
.build(); .build();
} }
private McpToolResponse createConversationRetrieveTool() {
Map<String, Object> 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() { private McpPromptResponse createMemorySummaryPrompt() {
List<McpPromptResponse.PromptArgument> arguments = new ArrayList<>(); List<McpPromptResponse.PromptArgument> arguments = new ArrayList<>();

View File

@ -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<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
RedisTemplate<String, Object> 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;
}
}

View File

@ -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;
}

View File

@ -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<Conversation> getRecentConversations(String userId, Integer limit);
/**
* 특정 사용자의 모든 대화 조회
*
* @param userId 사용자 ID
* @return 대화 목록
*/
List<Conversation> getAllConversationsByUserId(String userId);
/**
* 대화 삭제
*
* @param conversationId 대화 ID
*/
void deleteConversation(String conversationId);
}

View File

@ -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<String, Object> 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<Conversation> 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<Object> conversationIds = redisTemplate.opsForZSet()
.reverseRange(userConversationsKey, 0, limitValue - 1);
if (conversationIds == null || conversationIds.isEmpty()) {
return List.of();
}
// 대화 ID로 실제 대화 데이터 조회
List<Conversation> 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<Conversation> getAllConversationsByUserId(String userId) {
log.info("사용자 전체 대화 조회: userId={}", userId);
String userConversationsKey = USER_CONVERSATIONS_KEY_PREFIX + userId;
// Sorted Set에서 모든 대화 ID 조회
Set<Object> conversationIds = redisTemplate.opsForZSet()
.reverseRange(userConversationsKey, 0, -1);
if (conversationIds == null || conversationIds.isEmpty()) {
return List.of();
}
// 대화 ID로 실제 대화 데이터 조회
List<Conversation> 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);
}
}