parent
ab6bca3e53
commit
ec0c8c55ba
|
|
@ -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 "{}";
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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<>();
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Loading…
Reference in New Issue