Compare commits
8 Commits
main
...
all-mpnet-
| Author | SHA1 | Date |
|---|---|---|
|
|
f28a530e85 | |
|
|
2a74e6a8e7 | |
|
|
e1937aea60 | |
|
|
d01de88078 | |
|
|
66e02868a6 | |
|
|
9d4906a141 | |
|
|
ec0c8c55ba | |
|
|
ab6bca3e53 |
12
API명세서.md
12
API명세서.md
|
|
@ -557,19 +557,21 @@ MCP 서버를 초기화하고 서버 정보 및 지원 기능을 반환합니다
|
||||||
"jsonrpc": "2.0",
|
"jsonrpc": "2.0",
|
||||||
"id": "8",
|
"id": "8",
|
||||||
"result": {
|
"result": {
|
||||||
|
"contents": [
|
||||||
|
{
|
||||||
"uri": "memory://recent",
|
"uri": "memory://recent",
|
||||||
"mimeType": "application/json",
|
"mimeType": "application/json",
|
||||||
"text": "{\"message\": \"Resource content for memory://recent\"}",
|
"text": "{\"message\": \"Resource content for memory://recent\"}"
|
||||||
"metadata": {
|
|
||||||
"uri": "memory://recent",
|
|
||||||
"timestamp": 1701234567890
|
|
||||||
}
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**응답 DTO**: `McpResourceResponse.ResourceReadResponse`
|
**응답 DTO**: `McpResourceResponse.ResourceReadResponse`
|
||||||
|
|
||||||
|
**참고:** MCP 프로토콜에 따라 `contents` 필드는 배열이며 필수입니다. 각 요소는 `uri`, `mimeType`, `text` (또는 바이너리 리소스의 경우 `blob`)를 포함합니다.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 7. prompts/list
|
### 7. prompts/list
|
||||||
|
|
@ -932,7 +934,7 @@ public class MemoryResponse {
|
||||||
- ✅ `capabilities`에 `supported` 필드 추가
|
- ✅ `capabilities`에 `supported` 필드 추가
|
||||||
- ✅ JSON-RPC 2.0 표준 준수 (정상 응답에 error 필드 제외)
|
- ✅ JSON-RPC 2.0 표준 준수 (정상 응답에 error 필드 제외)
|
||||||
- ✅ 요청/응답 전체 로깅 기능 추가
|
- ✅ 요청/응답 전체 로깅 기능 추가
|
||||||
- ⏳ Redis 연동 (TODO)
|
- ✅ Redis 연동 (임시 메모리 저장/조회 완료, TTL 3일)
|
||||||
- ⏳ 벡터 검색 구현 (TODO)
|
- ⏳ 벡터 검색 구현 (TODO)
|
||||||
- ⏳ 배치 처리 구현 (TODO)
|
- ⏳ 배치 처리 구현 (TODO)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -39,8 +39,8 @@ AI ↔ Spring Boot(Dewey)
|
||||||
│ ├── api <-- 외부로 노출되는 REST API 계층
|
│ ├── api <-- 외부로 노출되는 REST API 계층
|
||||||
│ │ ├── controller <-- 요청을 받아 Service 호출하는 곳 (Endpoint)
|
│ │ ├── controller <-- 요청을 받아 Service 호출하는 곳 (Endpoint)
|
||||||
│ │ └── dto <-- Controller에서 사용하는 DTO
|
│ │ └── dto <-- Controller에서 사용하는 DTO
|
||||||
│ │ ├── request <-- 요청 DTO
|
│ │ ├── request <-- 요청 DTO
|
||||||
│ │ └── response <-- 응답 DTO
|
│ │ └── response <-- 응답 DTO
|
||||||
│ │
|
│ │
|
||||||
│ ├── config <-- Spring 설정, Bean 등록, CORS, Filter, ModelConfig 등
|
│ ├── config <-- Spring 설정, Bean 등록, CORS, Filter, ModelConfig 등
|
||||||
│ │
|
│ │
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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 '애플리케이션은 테이블/인덱스 생성 및 데이터 조작만 가능합니다.'
|
||||||
|
|
||||||
|
|
@ -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 버튜버 방송에서 사용자와의 모든 대화를 체계적으로 기록하고 활용하세요.
|
||||||
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -154,13 +163,24 @@ public class McpController {
|
||||||
case "tools/list" -> mcpService.listTools();
|
case "tools/list" -> mcpService.listTools();
|
||||||
case "tools/call" -> {
|
case "tools/call" -> {
|
||||||
Object args = params != null ? params.getArguments() : null;
|
Object args = params != null ? params.getArguments() : null;
|
||||||
|
String toolName = null;
|
||||||
|
Object toolArguments = null;
|
||||||
|
|
||||||
if (args instanceof Map) {
|
if (args instanceof Map) {
|
||||||
Map<String, Object> callArgs = (Map<String, Object>) args;
|
Map<String, Object> callArgs = (Map<String, Object>) args;
|
||||||
String toolName = callArgs.get("name") != null ? callArgs.get("name").toString() : null;
|
toolName = callArgs.get("name") != null ? callArgs.get("name").toString() : null;
|
||||||
Object toolArguments = callArgs.get("arguments");
|
toolArguments = callArgs.get("arguments");
|
||||||
yield mcpService.callTool(toolName, toolArguments);
|
|
||||||
}
|
}
|
||||||
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/list" -> mcpService.listResources();
|
||||||
case "resources/read" -> {
|
case "resources/read" -> {
|
||||||
|
|
@ -185,4 +205,307 @@ 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 {
|
||||||
|
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 "{}";
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ import lombok.Data;
|
||||||
import lombok.NoArgsConstructor;
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* MCP Resource 응답 DTO (View)
|
* MCP Resource 응답 DTO (View)
|
||||||
|
|
@ -37,11 +36,18 @@ public class McpResourceResponse {
|
||||||
@Builder
|
@Builder
|
||||||
@NoArgsConstructor
|
@NoArgsConstructor
|
||||||
@AllArgsConstructor
|
@AllArgsConstructor
|
||||||
public static class ResourceReadResponse {
|
public static class ResourceContent {
|
||||||
private String uri;
|
private String uri;
|
||||||
private String mimeType;
|
private String mimeType;
|
||||||
private String text;
|
private String text;
|
||||||
private Map<String, Object> metadata;
|
}
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public static class ResourceReadResponse {
|
||||||
|
private List<ResourceContent> contents;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,10 @@ public class McpToolCallResponse {
|
||||||
@JsonProperty("isError")
|
@JsonProperty("isError")
|
||||||
private Boolean isError;
|
private Boolean isError;
|
||||||
|
|
||||||
private String content;
|
/**
|
||||||
|
* MCP 표준 content 필드 (예: [{"type": "text", "text": "..."}])
|
||||||
|
*/
|
||||||
|
private List<Map<String, Object>> content;
|
||||||
|
|
||||||
private List<Map<String, Object>> parts;
|
private List<Map<String, Object>> parts;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -95,6 +99,15 @@ public class McpServiceImpl implements McpService {
|
||||||
metadata.put("timestamp", System.currentTimeMillis());
|
metadata.put("timestamp", System.currentTimeMillis());
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
if (name == null) {
|
||||||
|
return McpToolCallResponse.builder()
|
||||||
|
.isError(true)
|
||||||
|
.content(textContent("Unknown tool: null"))
|
||||||
|
.parts(Collections.emptyList())
|
||||||
|
.metadata(metadata)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
switch (name) {
|
switch (name) {
|
||||||
case "store_memory" -> {
|
case "store_memory" -> {
|
||||||
String userId = (String) args.get("user_id");
|
String userId = (String) args.get("user_id");
|
||||||
|
|
@ -102,11 +115,20 @@ public class McpServiceImpl implements McpService {
|
||||||
Integer importance = args.get("importance") != null ?
|
Integer importance = args.get("importance") != null ?
|
||||||
((Number) args.get("importance")).intValue() : 1;
|
((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);
|
memoryService.storeTemporaryMemory(userId, memoryText, importance);
|
||||||
|
|
||||||
return McpToolCallResponse.builder()
|
return McpToolCallResponse.builder()
|
||||||
.isError(false)
|
.isError(false)
|
||||||
.content("Memory stored successfully")
|
.content(textContent("Memory stored successfully"))
|
||||||
.parts(Collections.emptyList())
|
.parts(Collections.emptyList())
|
||||||
.metadata(metadata)
|
.metadata(metadata)
|
||||||
.build();
|
.build();
|
||||||
|
|
@ -116,11 +138,20 @@ public class McpServiceImpl implements McpService {
|
||||||
Integer limit = args.get("limit") != null ?
|
Integer limit = args.get("limit") != null ?
|
||||||
((Number) args.get("limit")).intValue() : 10;
|
((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);
|
var memories = memoryService.getPermanentMemories(userId, limit);
|
||||||
|
|
||||||
return McpToolCallResponse.builder()
|
return McpToolCallResponse.builder()
|
||||||
.isError(false)
|
.isError(false)
|
||||||
.content("Retrieved " + memories.size() + " memories")
|
.content(textContent("Retrieved " + memories.size() + " memories"))
|
||||||
.parts(Collections.emptyList())
|
.parts(Collections.emptyList())
|
||||||
.metadata(metadata)
|
.metadata(metadata)
|
||||||
.build();
|
.build();
|
||||||
|
|
@ -131,11 +162,43 @@ public class McpServiceImpl implements McpService {
|
||||||
Integer limit = args.get("limit") != null ?
|
Integer limit = args.get("limit") != null ?
|
||||||
((Number) args.get("limit")).intValue() : 5;
|
((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);
|
var memories = memoryService.searchMemoriesByVector(query, userId, limit);
|
||||||
|
|
||||||
return McpToolCallResponse.builder()
|
return McpToolCallResponse.builder()
|
||||||
.isError(false)
|
.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())
|
.parts(Collections.emptyList())
|
||||||
.metadata(metadata)
|
.metadata(metadata)
|
||||||
.build();
|
.build();
|
||||||
|
|
@ -143,7 +206,7 @@ public class McpServiceImpl implements McpService {
|
||||||
default -> {
|
default -> {
|
||||||
return McpToolCallResponse.builder()
|
return McpToolCallResponse.builder()
|
||||||
.isError(true)
|
.isError(true)
|
||||||
.content("Unknown tool: " + name)
|
.content(textContent("Unknown tool: " + name))
|
||||||
.parts(Collections.emptyList())
|
.parts(Collections.emptyList())
|
||||||
.metadata(metadata)
|
.metadata(metadata)
|
||||||
.build();
|
.build();
|
||||||
|
|
@ -153,7 +216,7 @@ public class McpServiceImpl implements McpService {
|
||||||
log.error("Tool execution error: {}", e.getMessage(), e);
|
log.error("Tool execution error: {}", e.getMessage(), e);
|
||||||
return McpToolCallResponse.builder()
|
return McpToolCallResponse.builder()
|
||||||
.isError(true)
|
.isError(true)
|
||||||
.content("Error: " + e.getMessage())
|
.content(textContent("Error: " + e.getMessage()))
|
||||||
.parts(Collections.emptyList())
|
.parts(Collections.emptyList())
|
||||||
.metadata(metadata)
|
.metadata(metadata)
|
||||||
.build();
|
.build();
|
||||||
|
|
@ -187,15 +250,18 @@ public class McpServiceImpl implements McpService {
|
||||||
public McpResourceResponse.ResourceReadResponse readResource(String uri) {
|
public McpResourceResponse.ResourceReadResponse readResource(String uri) {
|
||||||
log.info("MCP 리소스 읽기: uri={}", uri);
|
log.info("MCP 리소스 읽기: uri={}", uri);
|
||||||
|
|
||||||
Map<String, Object> metadata = new HashMap<>();
|
// MCP 프로토콜에 따라 contents 배열을 반환해야 함
|
||||||
metadata.put("uri", uri);
|
McpResourceResponse.ResourceContent content = McpResourceResponse.ResourceContent.builder()
|
||||||
metadata.put("timestamp", System.currentTimeMillis());
|
|
||||||
|
|
||||||
return McpResourceResponse.ResourceReadResponse.builder()
|
|
||||||
.uri(uri)
|
.uri(uri)
|
||||||
.mimeType("application/json")
|
.mimeType("application/json")
|
||||||
.text("{\"message\": \"Resource content for " + uri + "\"}")
|
.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();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -283,6 +349,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<>();
|
||||||
|
|
@ -313,4 +395,11 @@ public class McpServiceImpl implements McpService {
|
||||||
.arguments(arguments)
|
.arguments(arguments)
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MCP 표준 content 배열 생성 헬퍼
|
||||||
|
*/
|
||||||
|
private List<Map<String, Object>> textContent(String text) {
|
||||||
|
return List.of(Map.of("type", "text", "text", text));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -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.model.TemporaryMemory;
|
||||||
import com.pandol365.dewey.domain.memory.repository.MemoryRepository;
|
import com.pandol365.dewey.domain.memory.repository.MemoryRepository;
|
||||||
import com.pandol365.dewey.domain.memory.service.MemoryService;
|
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.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.data.domain.PageRequest;
|
import org.springframework.data.domain.PageRequest;
|
||||||
|
import org.springframework.data.redis.core.RedisTemplate;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -24,24 +30,46 @@ import java.util.UUID;
|
||||||
public class MemoryServiceImpl implements MemoryService {
|
public class MemoryServiceImpl implements MemoryService {
|
||||||
|
|
||||||
private final MemoryRepository memoryRepository;
|
private final MemoryRepository memoryRepository;
|
||||||
// TODO: Redis 연동 추가 필요
|
private final RedisTemplate<String, Object> redisTemplate;
|
||||||
// private final ReactiveRedisTemplate<String, TemporaryMemory> 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
|
@Override
|
||||||
public TemporaryMemory storeTemporaryMemory(String userId, String memoryText, Integer importance) {
|
public TemporaryMemory storeTemporaryMemory(String userId, String memoryText, Integer importance) {
|
||||||
log.info("임시 메모리 저장: userId={}, importance={}", userId, importance);
|
log.info("임시 메모리 저장: userId={}, importance={}", userId, importance);
|
||||||
|
|
||||||
// TODO: Redis에 저장하는 로직 구현
|
|
||||||
TemporaryMemory temporaryMemory = TemporaryMemory.builder()
|
TemporaryMemory temporaryMemory = TemporaryMemory.builder()
|
||||||
.id(UUID.randomUUID().toString())
|
.id(UUID.randomUUID().toString())
|
||||||
.userId(userId)
|
.userId(userId)
|
||||||
.memoryText(memoryText)
|
.memoryText(memoryText)
|
||||||
.importance(importance != null ? importance : 1)
|
.importance(importance != null ? importance : 1)
|
||||||
.createdAt(LocalDateTime.now())
|
.createdAt(LocalDateTime.now())
|
||||||
.expiresAt(LocalDateTime.now().plusDays(3)) // TTL 3일
|
.expiresAt(LocalDateTime.now().plusDays(TEMP_TTL_DAYS)) // TTL 3일
|
||||||
.build();
|
.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;
|
return temporaryMemory;
|
||||||
}
|
}
|
||||||
|
|
@ -51,8 +79,26 @@ public class MemoryServiceImpl implements MemoryService {
|
||||||
public List<TemporaryMemory> getTemporaryMemories(String userId) {
|
public List<TemporaryMemory> getTemporaryMemories(String userId) {
|
||||||
log.info("임시 메모리 조회: userId={}", userId);
|
log.info("임시 메모리 조회: userId={}", userId);
|
||||||
|
|
||||||
// TODO: Redis에서 조회하는 로직 구현
|
String userMemoriesKey = USER_TEMP_MEMORY_KEY_PREFIX + userId;
|
||||||
return List.of();
|
|
||||||
|
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
|
@Override
|
||||||
|
|
@ -75,10 +121,16 @@ public class MemoryServiceImpl implements MemoryService {
|
||||||
@Override
|
@Override
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
public List<Memory> searchMemoriesByVector(String query, String userId, Integer limit) {
|
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 사용)
|
int topK = limit != null && limit > 0 ? limit : 5;
|
||||||
return List.of();
|
try {
|
||||||
|
float[] embedding = embeddingClient.embed(query);
|
||||||
|
return memoryVectorStore.search(embedding, topK);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("벡터 검색 실패: {}", e.getMessage(), e);
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
spring.application.name=dewey
|
spring.application.name=dewey
|
||||||
|
|
||||||
# PostgreSQL
|
# 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.username=dewey
|
||||||
spring.datasource.password=0bk1rWu98mGl5ea3
|
spring.datasource.password=0bk1rWu98mGl5ea3
|
||||||
spring.datasource.driver-class-name=org.postgresql.Driver
|
spring.datasource.driver-class-name=org.postgresql.Driver
|
||||||
|
|
@ -15,3 +15,9 @@ spring.jpa.properties.hibernate.format_sql=true
|
||||||
# Redis
|
# Redis
|
||||||
spring.data.redis.host=localhost
|
spring.data.redis.host=localhost
|
||||||
spring.data.redis.port=6379
|
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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue