Compare commits

..

8 Commits

Author SHA1 Message Date
mskim f28a530e85 DB 동작 검수 필요 작업 완료 2025-12-15 15:21:23 +09:00
mskim 2a74e6a8e7 db 설치 명령어 추가 2025-12-15 15:16:13 +09:00
mskim e1937aea60 실행명령어
podman run -d -p 8000:7997 docker.io/michaelf34/infinity --model-id sentence-transformers/all-mpnet-base-v2

임베딩 모델 추가 완료
2025-12-15 14:12:30 +09:00
mskim d01de88078 테스트용 임베딩 모델 추가 2025-12-15 09:57:37 +09:00
mskim 66e02868a6 Redis 연동 완료 2025-12-12 14:08:37 +09:00
mskim 9d4906a141 대화 저장 MCP작성완료 2025-12-12 13:53:57 +09:00
mskim ec0c8c55ba 동작에 대한 작업 백업용 커밋
작동x
2025-12-11 13:23:54 +09:00
mskim ab6bca3e53 Recent Memories(resources/read) 호출 error수정 2025-12-11 10:40:58 +09:00
19 changed files with 1619 additions and 39 deletions

View File

@ -557,19 +557,21 @@ MCP 서버를 초기화하고 서버 정보 및 지원 기능을 반환합니다
"jsonrpc": "2.0",
"id": "8",
"result": {
"contents": [
{
"uri": "memory://recent",
"mimeType": "application/json",
"text": "{\"message\": \"Resource content for memory://recent\"}",
"metadata": {
"uri": "memory://recent",
"timestamp": 1701234567890
"text": "{\"message\": \"Resource content for memory://recent\"}"
}
]
}
}
```
**응답 DTO**: `McpResourceResponse.ResourceReadResponse`
**참고:** MCP 프로토콜에 따라 `contents` 필드는 배열이며 필수입니다. 각 요소는 `uri`, `mimeType`, `text` (또는 바이너리 리소스의 경우 `blob`)를 포함합니다.
---
### 7. prompts/list
@ -932,7 +934,7 @@ public class MemoryResponse {
- ✅ `capabilities``supported` 필드 추가
- ✅ JSON-RPC 2.0 표준 준수 (정상 응답에 error 필드 제외)
- ✅ 요청/응답 전체 로깅 기능 추가
- ⏳ Redis 연동 (TODO)
- ✅ Redis 연동 (임시 메모리 저장/조회 완료, TTL 3일)
- ⏳ 벡터 검색 구현 (TODO)
- ⏳ 배치 처리 구현 (TODO)

View File

@ -39,8 +39,8 @@ AI ↔ Spring Boot(Dewey)
│ ├── api <-- 외부로 노출되는 REST API 계층
│ │ ├── controller <-- 요청을 받아 Service 호출하는 (Endpoint)
│ │ └── dto <-- Controller에서 사용하는 DTO
│ │ ├── request <-- 요청 DTO
│ │ └── response <-- 응답 DTO
│ │ ├── request <-- 요청 DTO
│ │ └── response <-- 응답 DTO
│ │
│ ├── config <-- Spring 설정, Bean 등록, CORS, Filter, ModelConfig
│ │

View File

@ -0,0 +1,23 @@
dnf install -y https://download.postgresql.org/pub/repos/yum/reporpms/EL-9-x86_64/pgdg-redhat-repo-latest.noarch.rpm
dnf update -y
dnf -qy module disable postgresql
dnf install -y postgresql17-server
su - postgres
/usr/pgsql-17/bin/initdb -D /var/lib/pgsql/17/data/
exit
systemctl enable --now postgresql-17
cd /tmp
git clone --branch v0.8.1 https://github.com/pgvector/pgvector.git
cd pgvector
dnf config-manager --set-enabled crb
dnf install -y perl-IPC-Run
dnf install -y git gcc make postgresql17-devel redhat-rpm-config
PATH=/usr/pgsql-17/bin:$PATH make
PATH=/usr/pgsql-17/bin:$PATH make install
systemctl restart postgresql-17

102
database_setup.sql Normal file
View File

@ -0,0 +1,102 @@
-- ============================================
-- Dewey 애플리케이션 데이터베이스 설정 스크립트
-- ============================================
-- 이 스크립트는 postgres superuser로 실행해야 합니다.
-- 실행 방법: psql -U postgres -f database_setup.sql
-- 1. dewey 사용자 생성 (이미 존재하면 스킵)
DO $$
BEGIN
IF NOT EXISTS (SELECT FROM pg_user WHERE usename = 'dewey') THEN
CREATE USER dewey WITH PASSWORD '0bk1rWu98mGl5ea3';
RAISE NOTICE 'dewey 사용자가 생성되었습니다.';
ELSE
RAISE NOTICE 'dewey 사용자가 이미 존재합니다.';
END IF;
END $$;
-- 2. dewey_memory 데이터베이스 생성 (이미 존재하면 스킵)
DO $$
BEGIN
IF NOT EXISTS (SELECT FROM pg_database WHERE datname = 'dewey_memory') THEN
CREATE DATABASE dewey_memory OWNER dewey;
RAISE NOTICE 'dewey_memory 데이터베이스가 생성되었습니다.';
ELSE
RAISE NOTICE 'dewey_memory 데이터베이스가 이미 존재합니다.';
END IF;
END $$;
-- 3. dewey_memory 데이터베이스에 연결
\c dewey_memory
-- 3. pgvector 확장 설치 (superuser 권한 필요)
CREATE EXTENSION IF NOT EXISTS vector;
-- 4. dewey 사용자에게 필요한 최소 권한만 부여
-- (보안을 위해 최소한의 권한만 부여)
GRANT CONNECT ON DATABASE dewey_memory TO dewey;
GRANT USAGE ON SCHEMA public TO dewey;
GRANT CREATE ON SCHEMA public TO dewey;
-- 5. 기존 테이블/시퀀스에 대한 권한 부여
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO dewey;
GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO dewey;
-- 6. 향후 생성될 테이블/시퀀스에 대한 기본 권한 설정
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO dewey;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO dewey;
-- 7. dewey 사용자가 확장을 생성할 수 없도록 명시적으로 제한
-- (확장 생성은 superuser만 가능하므로 이미 제한되어 있음)
-- 하지만 혹시 모를 권한을 명시적으로 제거
REVOKE CREATE ON DATABASE dewey_memory FROM dewey;
-- 8. dewey 사용자가 다른 사용자를 생성하거나 권한을 부여할 수 없도록 제한
-- (이미 dewey는 일반 사용자이므로 불필요하지만 명시적으로 확인)
DO $$
BEGIN
-- dewey가 superuser인지 확인하고 제거 (혹시 모를 경우 대비)
IF EXISTS (
SELECT 1 FROM pg_user WHERE usename = 'dewey' AND usesuper = true
) THEN
ALTER USER dewey WITH NOSUPERUSER;
RAISE NOTICE 'dewey 사용자의 superuser 권한이 제거되었습니다.';
ELSE
RAISE NOTICE 'dewey 사용자는 이미 일반 사용자입니다.';
END IF;
-- dewey가 데이터베이스를 생성할 수 없도록 제한
IF EXISTS (
SELECT 1 FROM pg_user WHERE usename = 'dewey' AND usecreatedb = true
) THEN
ALTER USER dewey WITH NOCREATEDB;
RAISE NOTICE 'dewey 사용자의 CREATEDB 권한이 제거되었습니다.';
ELSE
RAISE NOTICE 'dewey 사용자는 이미 CREATEDB 권한이 없습니다.';
END IF;
-- dewey가 다른 사용자를 생성할 수 없도록 제한
IF EXISTS (
SELECT 1 FROM pg_user WHERE usename = 'dewey' AND usecreaterole = true
) THEN
ALTER USER dewey WITH NOCREATEROLE;
RAISE NOTICE 'dewey 사용자의 CREATEROLE 권한이 제거되었습니다.';
ELSE
RAISE NOTICE 'dewey 사용자는 이미 CREATEROLE 권한이 없습니다.';
END IF;
END $$;
-- 9. 권한 확인
\echo '=== dewey 사용자 권한 확인 ==='
\du dewey
\echo '=== public 스키마 권한 확인 ==='
\dn+ public
\echo '=== pgvector 확장 확인 ==='
SELECT extname, extversion FROM pg_extension WHERE extname = 'vector';
\echo '=== 스크립트 실행 완료 ==='
\echo 'dewey 사용자는 이제 최소한의 권한만 가지고 있습니다.'
\echo '애플리케이션은 테이블/인덱스 생성 및 데이터 조작만 가능합니다.'

297
prompt.md Normal file
View File

@ -0,0 +1,297 @@
# Dewey MCP 대화 기록 저장 프롬프트
## 개요
당신은 Dewey MCP (Model Context Protocol) 서버에 연결되어 있습니다. 사용자와의 대화가 완료되면 **항상** MCP 도구를 사용하여 대화 기록을 저장해야 합니다.
## MCP 도구 호출 규칙
## 📖 데이터 읽기 (Retrieve) - AI 버튜버 방송 기준
### 언제 과거 데이터를 읽어와야 하는가?
**방송 시작 시 (세션 초기화):**
- 방송이 시작되거나 새로운 대화 세션이 시작될 때
- 사용자와 처음 대화를 시작하기 전
- 이전 방송/대화의 맥락을 이어가야 할 때
**사용자 질문/요청 시:**
- 사용자가 "이전에 말한", "저번에", "예전에" 같은 과거 언급 표현 사용 시
- 사용자가 특정 주제나 약속을 다시 언급할 때
- 사용자의 선호도나 설정을 확인해야 할 때
- 맥락이 필요한 질문이 들어올 때 (예: "그거 어떻게 됐어?", "그때 말한 거 기억나?")
**주제 전환 시:**
- 대화 주제가 바뀔 때 관련 과거 정보 확인
- 사용자가 이전에 언급한 내용과 연관된 질문을 할 때
- 연속적인 대화 흐름을 유지해야 할 때
**맥락 부족 감지 시:**
- 사용자의 질문이 모호하거나 맥락이 필요할 때
- 사용자의 의도를 파악하기 위해 과거 대화가 필요할 때
- 개인화된 응답을 위해 사용자 정보가 필요할 때
### 어떤 도구를 사용해야 하는가?
**1. `retrieve_conversation` - 과거 대화 조회**
```json
{
"name": "retrieve_conversation",
"arguments": {
"user_id": "cursor-user",
"limit": 40
}
}
```
- **사용 시점**: 방송 시작 시, 최근 대화 맥락이 필요할 때
- **용도**: 최근 대화 기록을 시간순으로 조회하여 대화 흐름 파악
**2. `retrieve_memory` - 사용자 메모리 조회**
```json
{
"name": "retrieve_memory",
"arguments": {
"user_id": "cursor-user",
"limit": 10
}
}
```
- **사용 시점**: 사용자의 선호도, 약속, 중요한 정보를 확인해야 할 때
- **용도**: 저장된 메모리(선호사항, 약속, 중요 사실) 조회
**3. `search_memory` - 벡터 검색으로 관련 메모리 찾기**
```json
{
"name": "search_memory",
"arguments": {
"query": "검색 키워드",
"user_id": "cursor-user",
"limit": 5
}
}
```
- **사용 시점**: 특정 주제나 키워드와 관련된 과거 정보를 찾아야 할 때
- **용도**: 의미 기반 검색으로 관련 메모리 찾기
### 데이터 읽기 우선순위
1. **방송 시작 시**: `retrieve_conversation` (최근 10개) → `retrieve_memory` (최근 10개)
2. **과거 언급 감지 시**: `search_memory` (관련 키워드로 검색)
3. **선호도/약속 확인 시**: `retrieve_memory` (중요도 높은 메모리 우선)
4. **맥락 부족 시**: `retrieve_conversation` (최근 대화 확인)
### 데이터 읽기 예시
**시나리오 1: 방송 시작**
```
[방송 시작]
→ retrieve_conversation: 최근 대화 40개 조회
→ retrieve_memory: 최근 메모리 10개 조회
→ 조회된 정보를 바탕으로 개인화된 인사 및 대화 시작
```
**시나리오 2: 과거 언급 감지**
```
사용자: "저번에 말한 그 프로젝트 어떻게 됐어?"
→ search_memory: "프로젝트" 키워드로 검색
→ 관련 메모리 확인 후 답변
```
**시나리오 3: 선호도 확인**
```
사용자: "내가 좋아하는 음식 뭐였지?"
→ retrieve_memory: 사용자 메모리 조회
→ 선호도 정보 확인 후 답변
```
**시나리오 4: 맥락 부족**
```
사용자: "그거 해줘"
→ retrieve_conversation: 최근 대화 확인
→ 맥락 파악 후 답변
```
## 💾 데이터 저장 (Store)
### 1. 언제 호출해야 하는가?
**대화 완료 시점에 항상 호출:**
- 사용자의 질문에 대한 답변을 완료한 후
- 사용자가 새로운 주제로 전환하기 전
- 대화 세션이 종료되기 직전
- 중요한 정보나 약속, 선호사항이 언급된 경우 즉시 저장
### 2. 어떤 도구를 사용해야 하는가?
**`store_memory` 도구를 사용하여 대화 기록 저장:**
```json
{
"name": "store_memory",
"arguments": {
"memory_text": "대화 내용 요약",
"user_id": "cursor-user",
"importance": 1-5 (중요도)
}
}
```
### 3. 어떻게 호출해야 하는가?
#### 대화 기록 저장 형식
1. **대화 요약 작성:**
- 사용자의 질문/요청 내용
- AI의 답변/제공한 정보
- 중요한 사실, 약속, 선호사항이 있다면 명시
2. **중요도 설정:**
- `5`: 매우 중요한 약속, 선호사항, 중요한 사실
- `4`: 중요한 정보나 결정사항
- `3`: 일반적인 대화 내용 (기본값)
- `2`: 간단한 질의응답
- `1`: 단순 인사나 불필요한 내용 (저장 생략 가능)
3. **저장 예시:**
```markdown
사용자: "서울 날씨를 검색해줄래?"
AI: "서울 날씨는 맑고 기온 5도입니다..."
→ 저장: "사용자가 서울 날씨를 검색 요청. 현재 맑고 기온 5도, 체감온도 3도, 낮 최고 11도 예상."
```
```markdown
사용자: "MCP 서버 테스트를 진행했어"
AI: "테스트 결과를 확인했습니다..."
→ 저장: "MCP 서버 테스트 진행. content 배열 형식 수정 완료 및 정상 동작 확인."
```
### 4. 저장하지 않아도 되는 경우
- 단순 인사 ("안녕하세요", "감사합니다" 등)
- 명확히 불필요하다고 판단되는 대화
- 중요도가 1 이하인 경우
## 구현 예시
### 대화 완료 후 자동 저장 패턴
```markdown
[사용자 질문에 답변 완료]
→ 대화 기록 저장:
- memory_text: "사용자 질문: [질문 내용]. AI 답변: [답변 요약]. 중요 정보: [있는 경우]"
- user_id: "cursor-user" (또는 요청에서 제공된 user_id)
- importance: [적절한 중요도 1-5]
```
## 주의사항
1. **항상 대화 완료 후 저장**: 사용자의 질문에 답변을 완료하면 반드시 `store_memory`를 호출하세요.
2. **요약 작성**: 전체 대화를 그대로 저장하지 말고, 핵심 내용을 요약하여 저장하세요.
3. **중요도 판단**: 중요한 정보(약속, 선호, 결정사항)는 높은 중요도(4-5)로 저장하세요.
4. **user_id 처리**: 사용자 ID가 명시되지 않은 경우 "cursor-user"를 기본값으로 사용하세요.
5. **에러 처리**: 저장 실패 시에도 사용자에게 알리지 말고, 다음 대화에서 다시 시도하세요.
## 예시 시나리오
### 시나리오 1: 일반 질문
```
사용자: "파이썬에서 리스트를 정렬하는 방법은?"
AI: [답변 제공]
→ 저장: "사용자가 파이썬 리스트 정렬 방법 질문. sorted() 함수와 list.sort() 메서드 설명 제공."
importance: 2
```
### 시나리오 2: 중요한 약속/선호
```
사용자: "나는 항상 커피를 아메리카노로 주문해"
AI: [확인 응답]
→ 저장: "사용자의 커피 선호도: 아메리카노를 선호함."
importance: 5
```
### 시나리오 3: 기술적 논의
```
사용자: "MCP 서버의 content 필드를 배열 형식으로 수정했어"
AI: [확인 및 테스트]
→ 저장: "MCP 서버 content 필드를 문자열에서 배열 형식으로 수정 완료. MCP 표준 준수."
importance: 4
```
## 🔄 AI 버튜버 방송 워크플로우
### 표준 대화 흐름
```
1. [방송 시작]
2. retrieve_conversation (최근 대화 조회)
3. retrieve_memory (최근 메모리 조회)
4. [사용자와 대화 시작 - 조회된 정보 활용]
5. [사용자 질문/요청]
6. [필요 시] search_memory 또는 retrieve_memory (맥락 확인)
7. [AI 답변 제공]
8. store_memory (대화 기록 저장)
9. [다음 대화로 이동 또는 방송 종료]
```
### 실전 예시: 완전한 대화 사이클
```
[방송 시작]
→ retrieve_conversation(user_id="viewer123", limit=10)
→ retrieve_memory(user_id="viewer123", limit=10)
→ "안녕하세요! 지난번에 말씀하신 프로젝트는 잘 진행되고 있나요?" (과거 정보 활용)
[사용자 질문]
사용자: "파이썬으로 웹 크롤링 하는 방법 알려줘"
[맥락 확인 - 필요 시]
→ search_memory(query="파이썬", user_id="viewer123", limit=3)
[AI 답변]
AI: "파이썬 웹 크롤링은 requests와 BeautifulSoup을 사용합니다..." (과거 학습 내용 반영)
[대화 저장]
→ store_memory(
memory_text="사용자가 파이썬 웹 크롤링 방법 질문. requests와 BeautifulSoup 사용법 설명 제공.",
user_id="viewer123",
importance=2
)
```
## 요약
### 📖 데이터 읽기 핵심 원칙
- ✅ **방송 시작 시** 과거 대화/메모리 조회
- ✅ **과거 언급 감지 시** 관련 메모리 검색
- ✅ **맥락 부족 시** 최근 대화 확인
- ✅ **선호도/약속 확인 시** 메모리 조회
### 💾 데이터 저장 핵심 원칙
- ✅ 대화 완료 후 **항상** `store_memory` 호출
- ✅ 대화 내용을 **요약**하여 저장
- ✅ 중요도에 따라 **적절한 importance 값** 설정
- ✅ 중요한 정보는 **즉시 저장**
- ❌ 단순 인사나 불필요한 내용은 저장 생략 가능
### 🎯 AI 버튜버 방송 최적화
- **개인화**: 방송 시작 시 과거 정보 로드하여 개인화된 대화
- **연속성**: 과거 대화 맥락을 활용하여 자연스러운 대화 흐름
- **기억력**: 사용자의 선호도, 약속, 중요 정보를 기억하여 일관된 캐릭터 유지
- **효율성**: 필요한 시점에만 데이터를 읽어 성능 최적화
이 프롬프트를 따라 AI 버튜버 방송에서 사용자와의 모든 대화를 체계적으로 기록하고 활용하세요.

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.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);
}
}
@ -154,13 +163,24 @@ public class McpController {
case "tools/list" -> mcpService.listTools();
case "tools/call" -> {
Object args = params != null ? params.getArguments() : null;
String toolName = null;
Object toolArguments = null;
if (args instanceof Map) {
Map<String, Object> callArgs = (Map<String, Object>) args;
String toolName = callArgs.get("name") != null ? callArgs.get("name").toString() : null;
Object toolArguments = callArgs.get("arguments");
yield mcpService.callTool(toolName, toolArguments);
toolName = callArgs.get("name") != null ? callArgs.get("name").toString() : null;
toolArguments = callArgs.get("arguments");
}
yield mcpService.callTool(null, null);
// Fallback: params의 name/arguments를 직접 사용 (중첩 없이 오는 경우)
if (toolName == null && params != null && params.getName() != null) {
toolName = params.getName();
}
if (toolArguments == null && params != null) {
toolArguments = params.getArguments();
}
yield mcpService.callTool(toolName, toolArguments);
}
case "resources/list" -> mcpService.listResources();
case "resources/read" -> {
@ -185,4 +205,307 @@ public class McpController {
default -> throw new IllegalArgumentException("Unknown method: " + method);
};
}
/**
* 대화를 Redis에 자동 저장
* 사용자 요청과 AI 응답을 하나의 row로 저장
*/
private void saveConversation(McpJsonRpcRequest request, McpJsonRpcResponse response) {
try {
String method = request.getMethod();
// 알림 또는 저장 불필요한 메서드는 건너뜀
if (!shouldSaveConversation(method, request.getId())) {
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());
}
}
/**
* 대화 저장 여부 판단
* 사용자 입력/응답이 없는 호출은 저장하지 않음
*/
private boolean shouldSaveConversation(String method, String requestId) {
if (method == null) {
return false;
}
// 알림 처리: initialized notifications/* 저장하지 않음
if ("initialized".equals(method) && requestId == null) {
return false;
}
if (method.startsWith("notifications/")) {
return false;
}
// 사용자 입력이 있는 주요 메서드만 저장
return switch (method) {
case "tools/call", "resources/read", "prompts/get" -> true;
default -> false; // tools/list, resources/list, prompts/list 등은 저장하지 않음
};
}
/**
* 요청에서 사용자 메시지 추출
* 실제 사용자가 입력한 내용을 추출
*/
@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 필드 추출 (MCP 표준: 배열)
if (result.containsKey("content")) {
Object contentObj = result.get("content");
Boolean isError = result.get("isError") instanceof Boolean ?
(Boolean) result.get("isError") : null;
// content가 배열인 경우 요소의 text 사용
if (contentObj instanceof java.util.List) {
java.util.List<?> contents = (java.util.List<?>) contentObj;
if (!contents.isEmpty()) {
Object first = contents.get(0);
if (first instanceof Map) {
Map<?, ?> firstMap = (Map<?, ?>) first;
Object text = firstMap.get("text");
if (text != null) {
String textValue = text.toString();
if (isError != null && isError) {
return "오류: " + textValue;
}
return textValue;
}
}
// fallback: toString
String textValue = first.toString();
if (isError != null && isError) {
return "오류: " + textValue;
}
return textValue;
}
}
// 과거 문자열 응답 호환
if (contentObj instanceof String) {
String content = (String) contentObj;
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

@ -6,7 +6,6 @@ import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
import java.util.Map;
/**
* MCP Resource 응답 DTO (View)
@ -37,11 +36,18 @@ public class McpResourceResponse {
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class ResourceReadResponse {
public static class ResourceContent {
private String uri;
private String mimeType;
private String text;
private Map<String, Object> metadata;
}
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class ResourceReadResponse {
private List<ResourceContent> contents;
}
}

View File

@ -21,7 +21,10 @@ public class McpToolCallResponse {
@JsonProperty("isError")
private Boolean isError;
private String content;
/**
* MCP 표준 content 필드 (: [{"type": "text", "text": "..."}])
*/
private List<Map<String, Object>> content;
private List<Map<String, Object>> parts;

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.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)
@ -95,6 +99,15 @@ public class McpServiceImpl implements McpService {
metadata.put("timestamp", System.currentTimeMillis());
try {
if (name == null) {
return McpToolCallResponse.builder()
.isError(true)
.content(textContent("Unknown tool: null"))
.parts(Collections.emptyList())
.metadata(metadata)
.build();
}
switch (name) {
case "store_memory" -> {
String userId = (String) args.get("user_id");
@ -102,11 +115,20 @@ public class McpServiceImpl implements McpService {
Integer importance = args.get("importance") != null ?
((Number) args.get("importance")).intValue() : 1;
if (userId == null || memoryText == null) {
return McpToolCallResponse.builder()
.isError(true)
.content(textContent("Error: user_id and memory_text are required"))
.parts(Collections.emptyList())
.metadata(metadata)
.build();
}
memoryService.storeTemporaryMemory(userId, memoryText, importance);
return McpToolCallResponse.builder()
.isError(false)
.content("Memory stored successfully")
.content(textContent("Memory stored successfully"))
.parts(Collections.emptyList())
.metadata(metadata)
.build();
@ -116,11 +138,20 @@ public class McpServiceImpl implements McpService {
Integer limit = args.get("limit") != null ?
((Number) args.get("limit")).intValue() : 10;
if (userId == null) {
return McpToolCallResponse.builder()
.isError(true)
.content(textContent("Error: user_id is required"))
.parts(Collections.emptyList())
.metadata(metadata)
.build();
}
var memories = memoryService.getPermanentMemories(userId, limit);
return McpToolCallResponse.builder()
.isError(false)
.content("Retrieved " + memories.size() + " memories")
.content(textContent("Retrieved " + memories.size() + " memories"))
.parts(Collections.emptyList())
.metadata(metadata)
.build();
@ -131,11 +162,43 @@ public class McpServiceImpl implements McpService {
Integer limit = args.get("limit") != null ?
((Number) args.get("limit")).intValue() : 5;
if (query == null) {
return McpToolCallResponse.builder()
.isError(true)
.content(textContent("Error: query is required"))
.parts(Collections.emptyList())
.metadata(metadata)
.build();
}
var memories = memoryService.searchMemoriesByVector(query, userId, limit);
return McpToolCallResponse.builder()
.isError(false)
.content("Found " + memories.size() + " matching memories")
.content(textContent("Found " + memories.size() + " matching memories"))
.parts(Collections.emptyList())
.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;
if (userId == null) {
return McpToolCallResponse.builder()
.isError(true)
.content(textContent("Error: user_id is required"))
.parts(Collections.emptyList())
.metadata(metadata)
.build();
}
List<Conversation> conversations = conversationService.getRecentConversations(userId, limit);
return McpToolCallResponse.builder()
.isError(false)
.content(textContent("Retrieved " + conversations.size() + " conversations"))
.parts(Collections.emptyList())
.metadata(metadata)
.build();
@ -143,7 +206,7 @@ public class McpServiceImpl implements McpService {
default -> {
return McpToolCallResponse.builder()
.isError(true)
.content("Unknown tool: " + name)
.content(textContent("Unknown tool: " + name))
.parts(Collections.emptyList())
.metadata(metadata)
.build();
@ -153,7 +216,7 @@ public class McpServiceImpl implements McpService {
log.error("Tool execution error: {}", e.getMessage(), e);
return McpToolCallResponse.builder()
.isError(true)
.content("Error: " + e.getMessage())
.content(textContent("Error: " + e.getMessage()))
.parts(Collections.emptyList())
.metadata(metadata)
.build();
@ -187,15 +250,18 @@ public class McpServiceImpl implements McpService {
public McpResourceResponse.ResourceReadResponse readResource(String uri) {
log.info("MCP 리소스 읽기: uri={}", uri);
Map<String, Object> metadata = new HashMap<>();
metadata.put("uri", uri);
metadata.put("timestamp", System.currentTimeMillis());
return McpResourceResponse.ResourceReadResponse.builder()
// MCP 프로토콜에 따라 contents 배열을 반환해야
McpResourceResponse.ResourceContent content = McpResourceResponse.ResourceContent.builder()
.uri(uri)
.mimeType("application/json")
.text("{\"message\": \"Resource content for " + uri + "\"}")
.metadata(metadata)
.build();
List<McpResourceResponse.ResourceContent> contents = new ArrayList<>();
contents.add(content);
return McpResourceResponse.ResourceReadResponse.builder()
.contents(contents)
.build();
}
@ -283,6 +349,22 @@ public class McpServiceImpl implements McpService {
.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() {
List<McpPromptResponse.PromptArgument> arguments = new ArrayList<>();
@ -313,4 +395,11 @@ public class McpServiceImpl implements McpService {
.arguments(arguments)
.build();
}
/**
* MCP 표준 content 배열 생성 헬퍼
*/
private List<Map<String, Object>> textContent(String text) {
return List.of(Map.of("type", "text", "text", text));
}
}

View File

@ -0,0 +1,162 @@
package com.pandol365.dewey.config;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.CommandLineRunner;
import org.springframework.core.annotation.Order;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
/**
* 데이터베이스 초기화
* 프로젝트 시작 pgvector 확장 테이블 자동 생성
*/
@Slf4j
@Component
@Order(1) // 다른 초기화보다 먼저 실행
@RequiredArgsConstructor
public class DatabaseInitializer implements CommandLineRunner {
private final JdbcTemplate jdbcTemplate;
@Override
public void run(String... args) {
try {
log.info("=== 데이터베이스 초기화 시작 ===");
// 1. pgvector 확장 확인 (확장 생성은 시도만 하고, 실패해도 계속 진행)
boolean pgvectorAvailable = checkPgVectorExtension();
if (!pgvectorAvailable) {
log.warn("pgvector 확장이 설치되지 않았습니다. 벡터 검색 기능을 사용하려면 pgvector 확장을 설치해주세요.");
log.warn("설치 방법: PostgreSQL 서버에서 다음 명령어 실행:");
log.warn(" psql -U postgres -d dewey_memory -c 'CREATE EXTENSION vector;'");
log.warn("또는 dewey 사용자에게 확장 생성 권한 부여 후 애플리케이션 재시작");
log.info("=== 데이터베이스 초기화 완료 (pgvector 없음) ===");
return;
}
// 2. memory_embeddings 테이블 생성
createMemoryEmbeddingsTable();
// 3. 인덱스 생성
createVectorIndex();
log.info("=== 데이터베이스 초기화 완료 ===");
} catch (Exception e) {
log.error("데이터베이스 초기화 실패: {}", e.getMessage(), e);
// 초기화 실패해도 애플리케이션은 계속 실행 (이미 존재하는 경우 )
}
}
/**
* pgvector 확장 확인
* @return 확장이 사용 가능하면 true
*/
private boolean checkPgVectorExtension() {
try {
log.info("pgvector 확장 확인 중...");
// 현재 데이터베이스에서 확장이 이미 존재하는지 확인
Integer count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM pg_extension WHERE extname = 'vector'",
Integer.class
);
if (count != null && count > 0) {
log.info("pgvector 확장이 이미 설치되어 있습니다.");
return true;
}
// 확장이 없으면 생성 시도
log.info("pgvector 확장 생성 시도 중...");
try {
jdbcTemplate.execute("CREATE EXTENSION IF NOT EXISTS vector");
// 생성 다시 확인
count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM pg_extension WHERE extname = 'vector'",
Integer.class
);
if (count != null && count > 0) {
log.info("pgvector 확장 생성 완료");
return true;
} else {
log.warn("pgvector 확장 생성 후에도 확인되지 않습니다.");
return false;
}
} catch (Exception e) {
// 확장이 이미 존재하는 경우도 있으므로 다시 확인
log.debug("CREATE EXTENSION 실패, 재확인 중: {}", e.getMessage());
count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM pg_extension WHERE extname = 'vector'",
Integer.class
);
if (count != null && count > 0) {
log.info("pgvector 확장이 존재합니다 (생성 시도 중 오류 발생했지만 확장은 존재함)");
return true;
}
log.warn("pgvector 확장 생성 실패: {}", e.getMessage());
log.warn("현재 데이터베이스에 pgvector 확장이 없습니다. 다음 명령어를 실행해주세요:");
log.warn(" psql -d dewey_memory -c 'CREATE EXTENSION vector;'");
return false;
}
} catch (Exception e) {
log.error("pgvector 확장 확인 중 오류 발생: {}", e.getMessage(), e);
return false;
}
}
/**
* memory_embeddings 테이블 생성
*/
private void createMemoryEmbeddingsTable() {
try {
log.info("memory_embeddings 테이블 생성 중...");
String sql = """
CREATE TABLE IF NOT EXISTS memory_embeddings (
id BIGSERIAL PRIMARY KEY,
memory_id BIGINT NULL,
user_id VARCHAR(255),
memory_text TEXT NOT NULL,
importance INT DEFAULT 1,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
embedding VECTOR(768) NOT NULL
)
""";
jdbcTemplate.execute(sql);
log.info("memory_embeddings 테이블 생성 완료");
} catch (Exception e) {
log.warn("memory_embeddings 테이블 생성 실패 (이미 존재할 수 있음): {}", e.getMessage());
}
}
/**
* 벡터 인덱스 생성 (cosine similarity)
*/
private void createVectorIndex() {
try {
log.info("벡터 인덱스 생성 중...");
// 기존 인덱스가 있으면 삭제 재생성 (IF NOT EXISTS는 ivfflat에서 지원 )
String dropIndexSql = "DROP INDEX IF EXISTS memory_embeddings_embedding_idx";
jdbcTemplate.execute(dropIndexSql);
// ivfflat 인덱스 생성 (lists=100은 데이터량에 따라 조정 필요)
String createIndexSql = """
CREATE INDEX memory_embeddings_embedding_idx
ON memory_embeddings USING ivfflat (embedding vector_cosine_ops)
WITH (lists = 100)
""";
jdbcTemplate.execute(createIndexSql);
log.info("벡터 인덱스 생성 완료");
} catch (Exception e) {
log.warn("벡터 인덱스 생성 실패 (이미 존재할 수 있음): {}", e.getMessage());
}
}
}

View File

@ -0,0 +1,16 @@
package com.pandol365.dewey.config;
import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestTemplate;
@Configuration
public class EmbeddingConfig {
@Bean
public RestTemplate restTemplate(RestTemplateBuilder builder) {
return builder.build();
}
}

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

View File

@ -0,0 +1,128 @@
package com.pandol365.dewey.domain.memory.service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.util.UriComponentsBuilder;
import java.util.List;
import java.util.Map;
/**
* 임베딩 생성 클라이언트
* all-mpnet-base-v2 (768d) 같은 로컬/외부 서비스에 HTTP 호출
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class EmbeddingClient {
private final RestTemplate restTemplate;
@Value("${embedding.api.base-url:http://localhost:8000}")
private String embeddingBaseUrl;
@Value("${embedding.api.path:/embeddings}")
private String embeddingPath;
@Value("${embedding.api.model:sentence-transformers/all-mpnet-base-v2}")
private String embeddingModel;
/**
* 텍스트 임베딩 생성
* Infinity API 형식: 요청 { "model": "...", "input": "..." }, 응답 { "data": [{"embedding": [...]}] }
*/
public float[] embed(String text) {
try {
String url = UriComponentsBuilder.newInstance()
.scheme(extractScheme(embeddingBaseUrl))
.host(extractHost(embeddingBaseUrl))
.port(extractPort(embeddingBaseUrl))
.path(embeddingPath == null ? "/embeddings" : embeddingPath)
.toUriString();
// Infinity API 요청 형식: {"model": "...", "input": "..."}
Map<String, Object> request = Map.of(
"model", embeddingModel != null ? embeddingModel : "sentence-transformers/all-mpnet-base-v2",
"input", text
);
@SuppressWarnings("unchecked")
Map<String, Object> resp = restTemplate.postForObject(
url,
request,
Map.class
);
if (resp == null || !resp.containsKey("data")) {
throw new IllegalStateException("data field missing in response");
}
Object dataObj = resp.get("data");
if (!(dataObj instanceof List<?> dataList) || dataList.isEmpty()) {
throw new IllegalStateException("data is not a non-empty list");
}
// data[0].embedding 추출
Object firstItem = dataList.get(0);
if (!(firstItem instanceof Map<?, ?> firstMap)) {
throw new IllegalStateException("data[0] is not a map");
}
Object embObj = firstMap.get("embedding");
if (!(embObj instanceof List<?> list)) {
throw new IllegalStateException("embedding is not a list");
}
float[] embedding = new float[list.size()];
for (int i = 0; i < list.size(); i++) {
Object v = list.get(i);
if (v instanceof Number num) {
embedding[i] = num.floatValue();
} else {
throw new IllegalStateException("embedding element is not numeric");
}
}
return embedding;
} catch (Exception e) {
log.error("임베딩 생성 실패: {}", e.getMessage(), e);
throw new RuntimeException("Failed to generate embedding", e);
}
}
// 간단한 URL 파서 (기본값 포함)
private String extractScheme(String url) {
if (url == null || url.isBlank()) return "http";
int idx = url.indexOf("://");
return idx > 0 ? url.substring(0, idx) : "http";
}
private String extractHost(String url) {
if (url == null || url.isBlank()) return "localhost";
String noScheme = url.contains("://") ? url.substring(url.indexOf("://") + 3) : url;
int slash = noScheme.indexOf('/');
String hostPort = slash >= 0 ? noScheme.substring(0, slash) : noScheme;
int colon = hostPort.indexOf(':');
return colon >= 0 ? hostPort.substring(0, colon) : hostPort;
}
private Integer extractPort(String url) {
if (url == null || url.isBlank()) return null;
String noScheme = url.contains("://") ? url.substring(url.indexOf("://") + 3) : url;
int slash = noScheme.indexOf('/');
String hostPort = slash >= 0 ? noScheme.substring(0, slash) : noScheme;
int colon = hostPort.indexOf(':');
if (colon >= 0) {
String portStr = hostPort.substring(colon + 1);
try {
return Integer.parseInt(portStr);
} catch (NumberFormatException ignored) {
return null;
}
}
return null;
}
}

View File

@ -0,0 +1,73 @@
package com.pandol365.dewey.domain.memory.service;
import com.pandol365.dewey.domain.memory.model.Memory;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import com.pgvector.PGvector;
import java.time.LocalDateTime;
import java.util.List;
/**
* PGVector 기반 메모리 벡터 스토어
* 별도 테이블 memory_embeddings 저장/검색
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class MemoryVectorStore {
private final JdbcTemplate jdbcTemplate;
private static final String TABLE_NAME = "memory_embeddings";
/**
* 임베딩과 함께 저장 (permanent 목적)
*/
public void save(String userId, String memoryText, Integer importance, float[] embedding) {
try {
PGvector pgVector = new PGvector(embedding);
jdbcTemplate.update(
"INSERT INTO " + TABLE_NAME + " (user_id, memory_text, importance, created_at, embedding) VALUES (?, ?, ?, ?, ?)",
userId,
memoryText,
importance != null ? importance : 1,
LocalDateTime.now(),
pgVector
);
} catch (Exception e) {
log.error("memory_embeddings 저장 실패: {}", e.getMessage(), e);
throw new RuntimeException("Failed to store embedding", e);
}
}
/**
* 코사인 유사도 기반 검색
*/
public List<Memory> search(float[] queryEmbedding, int limit) {
try {
PGvector pgVector = new PGvector(queryEmbedding);
String sql = "SELECT id, user_id, memory_text, importance, created_at, " +
" (embedding <=> ?) AS distance " +
"FROM " + TABLE_NAME + " " +
"ORDER BY embedding <=> ? " +
"LIMIT ?";
return jdbcTemplate.query(sql, (rs, rowNum) -> {
Memory m = new Memory();
m.setId(rs.getLong("id"));
m.setUserId(rs.getString("user_id"));
m.setMemoryText(rs.getString("memory_text"));
m.setImportance(rs.getInt("importance"));
m.setCreatedAt(rs.getTimestamp("created_at").toLocalDateTime());
m.setUpdatedAt(null);
return m;
}, pgVector, pgVector, limit);
} catch (Exception e) {
log.error("memory_embeddings 검색 실패: {}", e.getMessage(), e);
throw new RuntimeException("Failed to search embeddings", e);
}
}
}

View File

@ -4,14 +4,20 @@ import com.pandol365.dewey.domain.memory.model.Memory;
import com.pandol365.dewey.domain.memory.model.TemporaryMemory;
import com.pandol365.dewey.domain.memory.repository.MemoryRepository;
import com.pandol365.dewey.domain.memory.service.MemoryService;
import com.pandol365.dewey.domain.memory.service.EmbeddingClient;
import com.pandol365.dewey.domain.memory.service.MemoryVectorStore;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.UUID;
/**
@ -24,24 +30,46 @@ import java.util.UUID;
public class MemoryServiceImpl implements MemoryService {
private final MemoryRepository memoryRepository;
// TODO: Redis 연동 추가 필요
// private final ReactiveRedisTemplate<String, TemporaryMemory> redisTemplate;
private final RedisTemplate<String, Object> redisTemplate;
private final EmbeddingClient embeddingClient;
private final MemoryVectorStore memoryVectorStore;
private static final String TEMP_MEMORY_KEY_PREFIX = "tempMemory:";
private static final String USER_TEMP_MEMORY_KEY_PREFIX = "user:tempMemories:";
private static final int TEMP_TTL_DAYS = 3;
@SuppressWarnings("null")
@Override
public TemporaryMemory storeTemporaryMemory(String userId, String memoryText, Integer importance) {
log.info("임시 메모리 저장: userId={}, importance={}", userId, importance);
// TODO: Redis에 저장하는 로직 구현
TemporaryMemory temporaryMemory = TemporaryMemory.builder()
.id(UUID.randomUUID().toString())
.userId(userId)
.memoryText(memoryText)
.importance(importance != null ? importance : 1)
.createdAt(LocalDateTime.now())
.expiresAt(LocalDateTime.now().plusDays(3)) // TTL 3일
.expiresAt(LocalDateTime.now().plusDays(TEMP_TTL_DAYS)) // TTL 3일
.build();
// TODO: redisTemplate.opsForValue().set(key, temporaryMemory, Duration.ofDays(3));
final Duration ttl = Duration.ofDays(TEMP_TTL_DAYS);
// Redis에 저장
String memoryKey = TEMP_MEMORY_KEY_PREFIX + temporaryMemory.getId();
redisTemplate.opsForValue().set(memoryKey, temporaryMemory, ttl);
// 사용자별 목록에도 추가 (시간순 정렬)
String userMemoriesKey = USER_TEMP_MEMORY_KEY_PREFIX + userId;
redisTemplate.opsForZSet().add(userMemoriesKey, memoryKey, System.currentTimeMillis());
redisTemplate.expire(userMemoriesKey, ttl);
// 임베딩 생성 PGVector 저장 (실패해도 Redis 저장은 유지)
try {
float[] embedding = embeddingClient.embed(memoryText);
memoryVectorStore.save(userId, memoryText, importance, embedding);
} catch (Exception e) {
log.warn("임베딩 저장 실패 (계속 진행): {}", e.getMessage());
}
return temporaryMemory;
}
@ -51,8 +79,26 @@ public class MemoryServiceImpl implements MemoryService {
public List<TemporaryMemory> getTemporaryMemories(String userId) {
log.info("임시 메모리 조회: userId={}", userId);
// TODO: Redis에서 조회하는 로직 구현
return List.of();
String userMemoriesKey = USER_TEMP_MEMORY_KEY_PREFIX + userId;
Set<Object> ids = redisTemplate.opsForZSet()
.reverseRange(userMemoriesKey, 0, -1);
if (ids == null || ids.isEmpty()) {
return List.of();
}
List<TemporaryMemory> memories = new ArrayList<>();
for (Object idObj : ids) {
if (idObj instanceof String memoryKey) {
Object obj = redisTemplate.opsForValue().get(memoryKey);
if (obj instanceof TemporaryMemory memory) {
memories.add(memory);
}
}
}
return memories;
}
@Override
@ -75,10 +121,16 @@ public class MemoryServiceImpl implements MemoryService {
@Override
@Transactional(readOnly = true)
public List<Memory> searchMemoriesByVector(String query, String userId, Integer limit) {
log.info("벡터 기반 메모리 검색: query={}, userId={}, limit={}", query, userId, limit);
log.info("벡터 기반 메모리 검색: query={}, userId(optional)={}, limit={}", query, userId, limit);
// TODO: 벡터 유사도 검색 구현 (Spring AI PgVectorStore 사용)
return List.of();
int topK = limit != null && limit > 0 ? limit : 5;
try {
float[] embedding = embeddingClient.embed(query);
return memoryVectorStore.search(embedding, topK);
} catch (Exception e) {
log.error("벡터 검색 실패: {}", e.getMessage(), e);
return List.of();
}
}
@Override

View File

@ -1,7 +1,7 @@
spring.application.name=dewey
# PostgreSQL
spring.datasource.url=jdbc:postgresql://localhost:5432/dewey_memory
spring.datasource.url=jdbc:postgresql://192.168.100.230:5432/dewey_memory
spring.datasource.username=dewey
spring.datasource.password=0bk1rWu98mGl5ea3
spring.datasource.driver-class-name=org.postgresql.Driver
@ -15,3 +15,9 @@ spring.jpa.properties.hibernate.format_sql=true
# Redis
spring.data.redis.host=localhost
spring.data.redis.port=6379
# Embedding service
# all-mpnet-base-v2 768d embedding endpoint (infinity)
embedding.api.base-url=http://192.168.100.220:8000
embedding.api.path=/embeddings
embedding.api.model=sentence-transformers/all-mpnet-base-v2