Spaces:
Runtime error
Runtime error
youdie006
commited on
Commit
·
065853d
0
Parent(s):
fix: token
Browse files- .dockerignore +48 -0
- .gitattributes +10 -0
- .gitignore +58 -0
- Dockerfile +56 -0
- LICENSE +21 -0
- README.md +57 -0
- aihub_Homework_LangChainAgents_20250601.ipynb +1031 -0
- aihub_Homework_LangChainAgents_20250601_gradio.ipynb +339 -0
- docker-compose.yml +38 -0
- load_data.py +80 -0
- main.py +104 -0
- requirements.txt +76 -0
- src/__init__.py +0 -0
- src/api/__init__.py +0 -0
- src/api/chat.py +113 -0
- src/api/openai.py +337 -0
- src/api/vector.py +272 -0
- src/core/__init__.py +0 -0
- src/core/vector_store.py +219 -0
- src/models/__init__.py +0 -0
- src/models/function_models.py +116 -0
- src/models/vector_models.py +170 -0
- src/services/__init__.py +0 -0
- src/services/aihub_processor.py +61 -0
- src/services/conversation_service.py +96 -0
- src/services/openai_client.py +133 -0
- src/utils/__init__.py +0 -0
- static/index.html +191 -0
.dockerignore
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# .dockerignore (SimSimi AI Agent 프로젝트 최종 버전)
|
| 2 |
+
|
| 3 |
+
# Git 관련 파일들
|
| 4 |
+
# 이미지 안에 버전 관리 히스토리가 포함될 필요가 없습니다.
|
| 5 |
+
.git
|
| 6 |
+
.gitignore
|
| 7 |
+
.gitattributes
|
| 8 |
+
|
| 9 |
+
# Docker 관련 파일들
|
| 10 |
+
# Dockerfile 자신이나 docker-compose 파일은 이미지에 포함되지 않습니다.
|
| 11 |
+
Dockerfile
|
| 12 |
+
docker-compose.yml
|
| 13 |
+
docker-compose.override.yml
|
| 14 |
+
|
| 15 |
+
# Python 캐시 및 가상 환경
|
| 16 |
+
# 로컬의 캐시나 가상환경이 이미지에 복사되는 것을 방지합니다.
|
| 17 |
+
__pycache__/
|
| 18 |
+
*.pyc
|
| 19 |
+
*.pyo
|
| 20 |
+
*.pyd
|
| 21 |
+
.venv
|
| 22 |
+
venv/
|
| 23 |
+
env/
|
| 24 |
+
|
| 25 |
+
# 민감 정보 (매우 중요)
|
| 26 |
+
# .env 파일은 이미지에 절대 포함되면 안 됩니다.
|
| 27 |
+
# 배포 환경에서는 'Secrets' 기능으로 주입해야 합니다.
|
| 28 |
+
.env
|
| 29 |
+
*.env
|
| 30 |
+
|
| 31 |
+
# 로컬 데이터 및 로그
|
| 32 |
+
# 데이터와 로그는 컨테이너 외부의 '볼륨(Volume)'으로 연결하여
|
| 33 |
+
# 영구적으로 관리하는 것이 원칙입니다.
|
| 34 |
+
data/
|
| 35 |
+
logs/
|
| 36 |
+
|
| 37 |
+
# IDE 및 OS 설정 파일
|
| 38 |
+
.idea/
|
| 39 |
+
.vscode/
|
| 40 |
+
*.swp
|
| 41 |
+
*.swo
|
| 42 |
+
.DS_Store
|
| 43 |
+
Thumbs.db
|
| 44 |
+
|
| 45 |
+
# 문서 및 설치 스크립트
|
| 46 |
+
# 실행되는 애플리케이션과 직접적인 관련이 없는 파일들입니다.
|
| 47 |
+
README.md
|
| 48 |
+
load_data.py
|
.gitattributes
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
data/conversations/conversations.db filter=lfs diff=lfs merge=lfs -text
|
| 2 |
+
data/chromadb/chroma.sqlite3 filter=lfs diff=lfs merge=lfs -text
|
| 3 |
+
data/chromadb/*.bin filter=lfs diff=lfs merge=lfs -text
|
| 4 |
+
data/chromadb/*.pickle filter=lfs diff=lfs merge=lfs -text
|
| 5 |
+
*.db filter=lfs diff=lfs merge=lfs -text
|
| 6 |
+
*.sqlite3 filter=lfs diff=lfs merge=lfs -text
|
| 7 |
+
*.bin filter=lfs diff=lfs merge=lfs -text
|
| 8 |
+
*.pickle filter=lfs diff=lfs merge=lfs -text
|
| 9 |
+
data/**/*.bin filter=lfs diff=lfs merge=lfs -text
|
| 10 |
+
data/**/*.pickle filter=lfs diff=lfs merge=lfs -text
|
.gitignore
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# .gitignore (SimSimi AI Agent 프로젝트 최종 버전)
|
| 2 |
+
|
| 3 |
+
# ===========================================
|
| 4 |
+
# 보안: 환경 변수 및 민감 정보 (절대 업로드 금지!)
|
| 5 |
+
# ===========================================
|
| 6 |
+
.env
|
| 7 |
+
*.env
|
| 8 |
+
.env.*
|
| 9 |
+
|
| 10 |
+
# ===========================================
|
| 11 |
+
# 데이터 및 로그
|
| 12 |
+
# data 폴더 전체를 무시하여, DB 파일 등이 올라가지 않도록 합니다.
|
| 13 |
+
# 배포 환경에서는 load_data.py를 통해 새로 생성하는 것이 원칙입니다.
|
| 14 |
+
# 응 업로드할거야.
|
| 15 |
+
# ===========================================
|
| 16 |
+
/data/
|
| 17 |
+
/logs/
|
| 18 |
+
*.log
|
| 19 |
+
*.db
|
| 20 |
+
*.sqlite3
|
| 21 |
+
*.bin
|
| 22 |
+
*.pickle
|
| 23 |
+
|
| 24 |
+
# ===========================================
|
| 25 |
+
# Python 자동 생성 파일
|
| 26 |
+
# ===========================================
|
| 27 |
+
__pycache__/
|
| 28 |
+
*.py[cod]
|
| 29 |
+
*$py.class
|
| 30 |
+
*.so
|
| 31 |
+
|
| 32 |
+
# ===========================================
|
| 33 |
+
# Python 가상 환경
|
| 34 |
+
# ===========================================
|
| 35 |
+
.venv/
|
| 36 |
+
venv/
|
| 37 |
+
env/
|
| 38 |
+
ENV/
|
| 39 |
+
|
| 40 |
+
# ===========================================
|
| 41 |
+
# IDE 및 시스템 설정 파일
|
| 42 |
+
# ===========================================
|
| 43 |
+
.vscode/
|
| 44 |
+
.idea/
|
| 45 |
+
*.swp
|
| 46 |
+
*.swo
|
| 47 |
+
.DS_Store
|
| 48 |
+
Thumbs.db
|
| 49 |
+
|
| 50 |
+
# ===========================================
|
| 51 |
+
# 의존성 및 빌드 관련
|
| 52 |
+
# ===========================================
|
| 53 |
+
.Python
|
| 54 |
+
build/
|
| 55 |
+
dist/
|
| 56 |
+
*.egg-info/
|
| 57 |
+
.installed.cfg
|
| 58 |
+
*.egg
|
Dockerfile
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Dockerfile
|
| 2 |
+
|
| 3 |
+
# 1. 베이스 이미지 설정
|
| 4 |
+
FROM python:3.10-slim
|
| 5 |
+
|
| 6 |
+
# 2. 메타데이터
|
| 7 |
+
LABEL maintainer="youdie006@naver.com"
|
| 8 |
+
LABEL description="SimSimi-based Conversational AI Agent"
|
| 9 |
+
LABEL version="1.0.0"
|
| 10 |
+
|
| 11 |
+
# 3. 시스템 의존성 설치
|
| 12 |
+
RUN apt-get update && apt-get install -y \
|
| 13 |
+
gcc \
|
| 14 |
+
g++ \
|
| 15 |
+
curl \
|
| 16 |
+
git \
|
| 17 |
+
git-lfs \
|
| 18 |
+
&& git lfs install \
|
| 19 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 20 |
+
|
| 21 |
+
# 4. 작업 디렉토리 설정
|
| 22 |
+
WORKDIR /app
|
| 23 |
+
|
| 24 |
+
# 5. 환경 변수 설정
|
| 25 |
+
ENV HF_HOME=/app/cache
|
| 26 |
+
ENV HF_DATASETS_CACHE=/app/cache
|
| 27 |
+
ENV TRANSFORMERS_CACHE=/app/cache
|
| 28 |
+
|
| 29 |
+
# 6. Python 의존성 설치
|
| 30 |
+
COPY requirements.txt .
|
| 31 |
+
RUN pip install --upgrade pip
|
| 32 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 33 |
+
|
| 34 |
+
# 7. [최종 수정] 데이터 다운로드를 빌드 단계에서 미리 실행
|
| 35 |
+
# 공개 데이터셋이므로 로그인 과정은 필요 없습니다.
|
| 36 |
+
# ARG HF_TOKEN <-- 이 줄 삭제
|
| 37 |
+
# RUN huggingface-cli login --token $HF_TOKEN <-- 이 줄 삭제
|
| 38 |
+
RUN huggingface-cli download \
|
| 39 |
+
youdie006/simsimi-ai-agent-data \
|
| 40 |
+
--repo-type dataset \
|
| 41 |
+
--local-dir /app/data \
|
| 42 |
+
--local-dir-use-symlinks False
|
| 43 |
+
RUN chmod -R 777 /app/data /app/cache
|
| 44 |
+
|
| 45 |
+
# 8. 애플리케이션 코드 복사
|
| 46 |
+
COPY . .
|
| 47 |
+
|
| 48 |
+
# 9. 포트 노출
|
| 49 |
+
EXPOSE 8000
|
| 50 |
+
|
| 51 |
+
# 10. 헬스체크
|
| 52 |
+
HEALTHCHECK --interval=30s --timeout=30s --start-period=5s --retries=3 \
|
| 53 |
+
CMD curl -f http://localhost:8000/api/v1/health || exit 1
|
| 54 |
+
|
| 55 |
+
# 11. 운영용 서버(Gunicorn)로 애플리케이션 실행
|
| 56 |
+
CMD ["gunicorn", "-w", "4", "-k", "uvicorn.workers.UvicornWorker", "-b", "0.0.0.0:8000", "main:app"]
|
LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
MIT License
|
| 2 |
+
|
| 3 |
+
Copyright (c) 2025 youdie006
|
| 4 |
+
|
| 5 |
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
| 6 |
+
of this software and associated documentation files (the "Software"), to deal
|
| 7 |
+
in the Software without restriction, including without limitation the rights
|
| 8 |
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
| 9 |
+
copies of the Software, and to permit persons to whom the Software is
|
| 10 |
+
furnished to do so, subject to the following conditions:
|
| 11 |
+
|
| 12 |
+
The above copyright notice and this permission notice shall be included in all
|
| 13 |
+
copies or substantial portions of the Software.
|
| 14 |
+
|
| 15 |
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
| 16 |
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
| 17 |
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
| 18 |
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
| 19 |
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
| 20 |
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
| 21 |
+
SOFTWARE.
|
README.md
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
# 이 부분은 Hugging Face Space 설정에만 사용됩니다.
|
| 3 |
+
# GitHub에서는 이 부분이 회색 코드 블록처럼 보입니다.
|
| 4 |
+
title: 마음이 - 청소년 공감 AI 챗봇
|
| 5 |
+
emoji: 💙
|
| 6 |
+
colorFrom: purple
|
| 7 |
+
colorTo: blue
|
| 8 |
+
sdk: docker
|
| 9 |
+
app_port: 8000
|
| 10 |
+
pinned: false
|
| 11 |
+
---
|
| 12 |
+
|
| 13 |
+
# 💙 마음이 - 청소년 공감 AI 챗봇
|
| 14 |
+
|
| 15 |
+
LLM과 고급 RAG(Retrieval-Augmented Generation) 파이프라인을 활용하여, 청소년들의 고민을 들어주고 공감해주는 AI 상담 챗봇 '마음이'입니다.
|
| 16 |
+
|
| 17 |
+
## 🚀 라이브 데모
|
| 18 |
+
|
| 19 |
+
https://huggingface.co/spaces/youdie006/simsimi_ai_agent
|
| 20 |
+
|
| 21 |
+
---
|
| 22 |
+
|
| 23 |
+
## 👨💻 개발자 및 평가자를 위한 가이드
|
| 24 |
+
|
| 25 |
+
### 주요 기능 및 기술적 특징
|
| 26 |
+
|
| 27 |
+
본 프로젝트는 단순한 RAG를 넘어, 실제 운영 환경에서 발생할 수 있는 문제들을 해결하기 위한 고급 기법들을 적용했습니다.
|
| 28 |
+
|
| 29 |
+
* **하이브리드 ReAct 파이프라인**: AI가 스스로 사고하고 행동하는 ReAct 패턴의 구조를 차용하되, Python 코드가 전체 흐름을 제어하여 안정성을 확보했습니다.
|
| 30 |
+
* **대화형 쿼리 재작성 (Conversational Query Rewriting)**: 사용자와의 이전 대화 맥락을 AI가 이해하여, VectorDB 검색에 가장 최적화된 검색어를 동적으로 생성합니다.
|
| 31 |
+
* **RAG 결과 검증 (Relevance Verification)**: 검색된 문서가 현재 대화와 정말 관련이 있는지 LLM을 통해 한번 더 검증하여, 관련 없는 정보가 답변에 사용되는 것을 원천적으로 차단합니다.
|
| 32 |
+
|
| 33 |
+
### 기술 스택
|
| 34 |
+
|
| 35 |
+
* **Backend**: FastAPI, Python
|
| 36 |
+
* **LLM**: OpenAI GPT-4
|
| 37 |
+
* **VectorDB**: ChromaDB
|
| 38 |
+
* **Embedding Model**: `jhgan/ko-sbert-multitask`
|
| 39 |
+
* **Deployment**: Docker, Hugging Face Spaces
|
| 40 |
+
|
| 41 |
+
### 설치 및 실행 방법
|
| 42 |
+
|
| 43 |
+
1. **저장소 클론 및 환경 설정**
|
| 44 |
+
```bash
|
| 45 |
+
git clone [https://github.com/youdie006/simsimi-ai-agent.git](https://github.com/youdie006/simsimi-ai-agent.git)
|
| 46 |
+
cd simsimi-ai-agent
|
| 47 |
+
# .env 파일 생성 및 OPENAI_API_KEY 설정
|
| 48 |
+
```
|
| 49 |
+
|
| 50 |
+
2. **데이터베이스 구축**
|
| 51 |
+
* (이곳에 `load_data.py` 실행 방법 등 데이터 구축 과정을 설명합니다.)
|
| 52 |
+
|
| 53 |
+
3. **로컬 실행**
|
| 54 |
+
```bash
|
| 55 |
+
docker-compose up --build
|
| 56 |
+
```
|
| 57 |
+
이후 `http://localhost:8000` 에서 실행을 확인합니다.
|
aihub_Homework_LangChainAgents_20250601.ipynb
ADDED
|
@@ -0,0 +1,1031 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"cells": [
|
| 3 |
+
{
|
| 4 |
+
"cell_type": "markdown",
|
| 5 |
+
"metadata": {
|
| 6 |
+
"id": "RPUwOvgUyZiz"
|
| 7 |
+
},
|
| 8 |
+
"source": [
|
| 9 |
+
"requiremenrs.txt\n",
|
| 10 |
+
"\n",
|
| 11 |
+
"langchain\n",
|
| 12 |
+
"langchain-openai\n",
|
| 13 |
+
"langchainhub # langchain python라이브러리로 프롬프트, 에이전트, 체인 관련 패키지 모음\n",
|
| 14 |
+
"langserve[all]\n",
|
| 15 |
+
"\n",
|
| 16 |
+
"faiss-cpu # Facebook에서 개발 및 배포한 밀집 벡터의 유사도 측정, 클러스터링에 효율적인 라이브러리\n",
|
| 17 |
+
"tavily-python # 언어 모델에 중립적인 디자인으로, 모든 LLM과 통합이 가능하도록 설계된 검색 API\n",
|
| 18 |
+
"beautifulsoup4 #파이썬에서 사용할 수 있는 웹데이터 크롤링 라이브러리\n",
|
| 19 |
+
"wikipedia\n",
|
| 20 |
+
"\n",
|
| 21 |
+
"fastapi # Python의 API를 빌드하기 위한 웹 프레임워크\n",
|
| 22 |
+
"uvicorn # ASGI(Asynchronous Server Gateway Interface) 서버\n",
|
| 23 |
+
"urllib3 # 파이썬에서 HTTP 요청을 보내고 받는 데 사용되는 강력하고 유연한 라이브러리\n",
|
| 24 |
+
"\n",
|
| 25 |
+
"python-dotenv\n",
|
| 26 |
+
"pypdf"
|
| 27 |
+
]
|
| 28 |
+
},
|
| 29 |
+
{
|
| 30 |
+
"cell_type": "code",
|
| 31 |
+
"execution_count": null,
|
| 32 |
+
"metadata": {
|
| 33 |
+
"id": "NMMJXo_JyjhQ"
|
| 34 |
+
},
|
| 35 |
+
"outputs": [],
|
| 36 |
+
"source": [
|
| 37 |
+
"!pip install langchain\n",
|
| 38 |
+
"!pip install langchain-openai\n",
|
| 39 |
+
"!pip install python-dotenv\n",
|
| 40 |
+
"!pip install langchain_community\n",
|
| 41 |
+
"!pip install pypdf\n",
|
| 42 |
+
"!pip install faiss-cpu\n",
|
| 43 |
+
"!pip install wikipedia\n",
|
| 44 |
+
"!pip install openai"
|
| 45 |
+
]
|
| 46 |
+
},
|
| 47 |
+
{
|
| 48 |
+
"cell_type": "markdown",
|
| 49 |
+
"metadata": {
|
| 50 |
+
"id": "jXEvb3WyJMcA"
|
| 51 |
+
},
|
| 52 |
+
"source": [
|
| 53 |
+
"Tavily Search 를 사용하기 위해서는 API KEY를 발급 받아 등록해야 함.\n",
|
| 54 |
+
"\n",
|
| 55 |
+
"[Tavily Search API 발급받기](https://app.tavily.com/sign-in)\n",
|
| 56 |
+
"\n",
|
| 57 |
+
"발급 받은 API KEY 를 다음과 같이 환경변수에 등록"
|
| 58 |
+
]
|
| 59 |
+
},
|
| 60 |
+
{
|
| 61 |
+
"cell_type": "code",
|
| 62 |
+
"execution_count": null,
|
| 63 |
+
"metadata": {
|
| 64 |
+
"id": "RIxxUDEZI6ZR"
|
| 65 |
+
},
|
| 66 |
+
"outputs": [],
|
| 67 |
+
"source": [
|
| 68 |
+
"import os\n",
|
| 69 |
+
"\n",
|
| 70 |
+
"# TAVILY API KEY를 기입합니다.\n",
|
| 71 |
+
"os.environ[\"TAVILY_API_KEY\"] = \"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\"\n",
|
| 72 |
+
"\n",
|
| 73 |
+
"# 디버깅을 위한 프로젝트명을 기입합니다.\n",
|
| 74 |
+
"os.environ[\"LANGCHAIN_PROJECT\"] = \"AGENT TUTORIAL\""
|
| 75 |
+
]
|
| 76 |
+
},
|
| 77 |
+
{
|
| 78 |
+
"cell_type": "code",
|
| 79 |
+
"execution_count": null,
|
| 80 |
+
"metadata": {
|
| 81 |
+
"id": "ys24Z3bfJHUf"
|
| 82 |
+
},
|
| 83 |
+
"outputs": [],
|
| 84 |
+
"source": [
|
| 85 |
+
"os.environ[\"OPENAI_API_KEY\"] = ''"
|
| 86 |
+
]
|
| 87 |
+
},
|
| 88 |
+
{
|
| 89 |
+
"cell_type": "code",
|
| 90 |
+
"execution_count": null,
|
| 91 |
+
"metadata": {
|
| 92 |
+
"id": "sEii2SHNJbAG"
|
| 93 |
+
},
|
| 94 |
+
"outputs": [],
|
| 95 |
+
"source": [
|
| 96 |
+
"# API KEY를 환경변수로 관리하기 위한 설정 파일\n",
|
| 97 |
+
"from dotenv import load_dotenv\n",
|
| 98 |
+
"\n",
|
| 99 |
+
"# API KEY 정보로드\n",
|
| 100 |
+
"load_dotenv()"
|
| 101 |
+
]
|
| 102 |
+
},
|
| 103 |
+
{
|
| 104 |
+
"cell_type": "code",
|
| 105 |
+
"execution_count": null,
|
| 106 |
+
"metadata": {
|
| 107 |
+
"id": "ezbT1NHQKP12"
|
| 108 |
+
},
|
| 109 |
+
"outputs": [],
|
| 110 |
+
"source": [
|
| 111 |
+
"#google drive load\n",
|
| 112 |
+
"from google.colab import drive\n",
|
| 113 |
+
"drive.mount('/content/drive')"
|
| 114 |
+
]
|
| 115 |
+
},
|
| 116 |
+
{
|
| 117 |
+
"cell_type": "code",
|
| 118 |
+
"execution_count": null,
|
| 119 |
+
"metadata": {
|
| 120 |
+
"id": "rKz2oCpl6lWK"
|
| 121 |
+
},
|
| 122 |
+
"outputs": [],
|
| 123 |
+
"source": [
|
| 124 |
+
"# training_dir_path = '/content/drive/MyDrive/2025_Bigdata_nlp_class/aihub_dataset/Training/02_label_data'\n",
|
| 125 |
+
"# validation_dir_path = '/content/drive/MyDrive/2025_Bigdata_nlp_class/aihub_dataset/Validation/02_label_data'"
|
| 126 |
+
]
|
| 127 |
+
},
|
| 128 |
+
{
|
| 129 |
+
"cell_type": "markdown",
|
| 130 |
+
"metadata": {
|
| 131 |
+
"id": "sWR_5-ANKJt-"
|
| 132 |
+
},
|
| 133 |
+
"source": [
|
| 134 |
+
"search.invoke 함수는 주어진 문자열에 대한 검색을 실행\n",
|
| 135 |
+
"\n",
|
| 136 |
+
"invoke() 함수에 검색하고 싶은 검색어를 넣어 검색을 수행"
|
| 137 |
+
]
|
| 138 |
+
},
|
| 139 |
+
{
|
| 140 |
+
"cell_type": "markdown",
|
| 141 |
+
"metadata": {
|
| 142 |
+
"id": "5mHSqB3_Kvf3"
|
| 143 |
+
},
|
| 144 |
+
"source": [
|
| 145 |
+
"#PDF 기반 문서 검색 도구: Retriever\n",
|
| 146 |
+
"\n",
|
| 147 |
+
"내부 데이터에 대해 조회를 수행할 retriever 생성.\n",
|
| 148 |
+
"\n",
|
| 149 |
+
"* 웹 기반 문서 로더, 문서 분할기, 벡터 저장소, 그리고 OpenAI 임베딩을 사용하여 문서 검색 시스템을 구축\n",
|
| 150 |
+
"* PDF 문서를 FAISS DB 에 저장하고 조회하는 retriever 를 생성\n",
|
| 151 |
+
"\n"
|
| 152 |
+
]
|
| 153 |
+
},
|
| 154 |
+
{
|
| 155 |
+
"cell_type": "markdown",
|
| 156 |
+
"metadata": {
|
| 157 |
+
"id": "IdP3zsje84fq"
|
| 158 |
+
},
|
| 159 |
+
"source": []
|
| 160 |
+
},
|
| 161 |
+
{
|
| 162 |
+
"cell_type": "code",
|
| 163 |
+
"execution_count": null,
|
| 164 |
+
"metadata": {
|
| 165 |
+
"id": "hnw_piOXK40_"
|
| 166 |
+
},
|
| 167 |
+
"outputs": [],
|
| 168 |
+
"source": [
|
| 169 |
+
"from langchain.text_splitter import RecursiveCharacterTextSplitter\n",
|
| 170 |
+
"from langchain_community.vectorstores import FAISS\n",
|
| 171 |
+
"from langchain_openai import OpenAIEmbeddings\n",
|
| 172 |
+
"from langchain.document_loaders import PyPDFLoader\n",
|
| 173 |
+
"from langchain_community.embeddings import HuggingFaceEmbeddings\n",
|
| 174 |
+
"import json\n",
|
| 175 |
+
"from langchain.document_loaders import TextLoader\n",
|
| 176 |
+
"from langchain.schema import Document\n",
|
| 177 |
+
"import unicodedata # Import the unicodedata module\n",
|
| 178 |
+
"\n",
|
| 179 |
+
"# PDF 파일 로드. 파일의 경로 입력\n",
|
| 180 |
+
"# 경로 설정\n",
|
| 181 |
+
"training_dir_path = \"/content/drive/MyDrive/2025_Bigdata_nlp_class/aihub_dataset/Training/02_label_data\"\n",
|
| 182 |
+
"\n",
|
| 183 |
+
"# for f in os.listdir(training_dir_path):\n",
|
| 184 |
+
"# print(repr(f)) # unicode escape로 특수문자 확인\n",
|
| 185 |
+
"\n",
|
| 186 |
+
"documents = []\n",
|
| 187 |
+
"# === 1. JSON 파일 로드 + 파일명 정규화 → 메타데이터 추출 ===\n",
|
| 188 |
+
"def load_documents_with_metadata(folder_path):\n",
|
| 189 |
+
" for raw_filename in os.listdir(folder_path):\n",
|
| 190 |
+
" # 파일 시스템에서 읽은 원래 이름을 정규화(NFC) 처리\n",
|
| 191 |
+
" filename = unicodedata.normalize(\"NFC\", raw_filename)\n",
|
| 192 |
+
" file_path = os.path.join(folder_path, raw_filename) # 실제 파일 경로는 raw_filename 써야 합니다.\n",
|
| 193 |
+
" print(f\"▶ 처리 중 파일 (원본): {filename}\")\n",
|
| 194 |
+
"\n",
|
| 195 |
+
" # 실제 파일인지 확인 (폴더나 시스템 파일 스킵)\n",
|
| 196 |
+
" if not os.path.isfile(file_path):\n",
|
| 197 |
+
" continue\n",
|
| 198 |
+
" # .json 확장자가 아닌 파일 스킵\n",
|
| 199 |
+
" if not filename.endswith(\".json\"):\n",
|
| 200 |
+
" continue\n",
|
| 201 |
+
"\n",
|
| 202 |
+
" try:\n",
|
| 203 |
+
" # 정규화된 파일명 출력 (한글 조합형 → 완성형으로 변환됐는지 확인)\n",
|
| 204 |
+
" print(f\"▶ 처리 중 파일 (정규화): {filename}\")\n",
|
| 205 |
+
"\n",
|
| 206 |
+
" # 정규화된 파일명을 \"_\"로 분리\n",
|
| 207 |
+
" # 예시: Empathy_기쁨_부모자녀_조손_343.json\n",
|
| 208 |
+
" parts = filename.replace(\".json\", \"\").split(\"_\")\n",
|
| 209 |
+
"\n",
|
| 210 |
+
" # parts[1] = 감정, parts[2] = 관계 (예: \"기쁨\", \"부모자녀\")\n",
|
| 211 |
+
" if len(parts) >= 3:\n",
|
| 212 |
+
" emotion = parts[1]\n",
|
| 213 |
+
" relation = parts[2]\n",
|
| 214 |
+
" else:\n",
|
| 215 |
+
" emotion = \"unknown\"\n",
|
| 216 |
+
" relation = \"unknown\"\n",
|
| 217 |
+
"\n",
|
| 218 |
+
" # JSON 읽기\n",
|
| 219 |
+
" with open(file_path, \"r\", encoding=\"utf-8\") as f:\n",
|
| 220 |
+
" data = json.load(f)\n",
|
| 221 |
+
" utterances = data.get(\"utterances\", [])\n",
|
| 222 |
+
"\n",
|
| 223 |
+
" # 대화 utterance만 합쳐서 하나의 긴 텍스트로 만듦\n",
|
| 224 |
+
" full_text = \"\\n\".join([utt.get(\"text\", \"\") for utt in utterances])\n",
|
| 225 |
+
"\n",
|
| 226 |
+
" # 텍스트가 비어 있으면 스킵\n",
|
| 227 |
+
" if full_text.strip() == \"\":\n",
|
| 228 |
+
" print(f\" ⚠️ 내용 비어 있음 → 스킵: {filename}\")\n",
|
| 229 |
+
" continue\n",
|
| 230 |
+
"\n",
|
| 231 |
+
" # Document 생성\n",
|
| 232 |
+
" doc = Document(\n",
|
| 233 |
+
" page_content=full_text,\n",
|
| 234 |
+
" metadata={\n",
|
| 235 |
+
" \"filename\": filename,\n",
|
| 236 |
+
" \"emotion\": emotion,\n",
|
| 237 |
+
" \"relation\": relation\n",
|
| 238 |
+
" }\n",
|
| 239 |
+
" )\n",
|
| 240 |
+
" documents.append(doc)\n",
|
| 241 |
+
"\n",
|
| 242 |
+
" except Exception as e:\n",
|
| 243 |
+
" print(f\"❌ 오류 발생 ({filename}): {e}\")\n",
|
| 244 |
+
"\n",
|
| 245 |
+
" return documents\n",
|
| 246 |
+
"\n",
|
| 247 |
+
"\n",
|
| 248 |
+
"documents = load_documents_with_metadata(training_dir_path)\n",
|
| 249 |
+
"print(f\"✅ 로드된 원본 문서 수: {len(documents)}\")"
|
| 250 |
+
]
|
| 251 |
+
},
|
| 252 |
+
{
|
| 253 |
+
"cell_type": "code",
|
| 254 |
+
"execution_count": null,
|
| 255 |
+
"metadata": {
|
| 256 |
+
"id": "d_1DPQehD6o6"
|
| 257 |
+
},
|
| 258 |
+
"outputs": [],
|
| 259 |
+
"source": [
|
| 260 |
+
"# === 2. 문서 분할 함수 ===\n",
|
| 261 |
+
"def split_documents(documents):\n",
|
| 262 |
+
" splitter = RecursiveCharacterTextSplitter(chunk_size=300, chunk_overlap=50)\n",
|
| 263 |
+
" return splitter.split_documents(documents)\n",
|
| 264 |
+
"\n",
|
| 265 |
+
"\n",
|
| 266 |
+
"# === 3. FAISS DB 생성 ===\n",
|
| 267 |
+
"def create_faiss_db(docs):\n",
|
| 268 |
+
" embeddings = OpenAIEmbeddings()\n",
|
| 269 |
+
" vectorstore = FAISS.from_documents(docs, embeddings)\n",
|
| 270 |
+
" return vectorstore\n",
|
| 271 |
+
"\n",
|
| 272 |
+
"def filtered_similarity_search(vectorstore, query, emotion=None, relation=None, k=3):\n",
|
| 273 |
+
" # docstore 내 모든 Document 객체 가져오기\n",
|
| 274 |
+
" all_docs = list(vectorstore.docstore._dict.values())\n",
|
| 275 |
+
"\n",
|
| 276 |
+
" # 1차 감정(emotion), 2차 관계(relation) 필터링\n",
|
| 277 |
+
" filtered_docs = []\n",
|
| 278 |
+
" for doc in all_docs:\n",
|
| 279 |
+
" doc_em = doc.metadata.get(\"emotion\", \"\")\n",
|
| 280 |
+
" doc_rel = doc.metadata.get(\"relation\", \"\")\n",
|
| 281 |
+
" if emotion and doc_em != emotion:\n",
|
| 282 |
+
" continue\n",
|
| 283 |
+
" if relation and doc_rel != relation:\n",
|
| 284 |
+
" continue\n",
|
| 285 |
+
" filtered_docs.append(doc)\n",
|
| 286 |
+
"\n",
|
| 287 |
+
" if not filtered_docs:\n",
|
| 288 |
+
" print(\"❗ 해당 감정/관계 조건의 문서가 없습니다.\")\n",
|
| 289 |
+
" return []\n",
|
| 290 |
+
"\n",
|
| 291 |
+
" # 필터링된 문서를 임베딩해서 별도의 FAISS 인덱스로 만들 수도 있지만,\n",
|
| 292 |
+
" # 여기서는 간단히 vectorstore.similarity_search() 호출 → 결과에서 필터 적용\n",
|
| 293 |
+
" # (단, 필요하다면 filtered_docs만으로 새로운 FAISS 인덱스를 생성 후 검색할 수도 있습니다.)\n",
|
| 294 |
+
" results = vectorstore.similarity_search(query, k=k)\n",
|
| 295 |
+
"\n",
|
| 296 |
+
" # 검색 결과 중에서도 meta 필터(감정/관계)가 맞는 것만 리턴\n",
|
| 297 |
+
" return [doc for doc in results\n",
|
| 298 |
+
" if (not emotion or doc.metadata.get(\"emotion\") == emotion)\n",
|
| 299 |
+
" and (not relation or doc.metadata.get(\"relation\") == relation)]"
|
| 300 |
+
]
|
| 301 |
+
},
|
| 302 |
+
{
|
| 303 |
+
"cell_type": "code",
|
| 304 |
+
"execution_count": null,
|
| 305 |
+
"metadata": {
|
| 306 |
+
"id": "_v_45KX6GKQ_"
|
| 307 |
+
},
|
| 308 |
+
"outputs": [],
|
| 309 |
+
"source": [
|
| 310 |
+
"# 2) 문서 분할 (chunking)\n",
|
| 311 |
+
"split_docs = split_documents(documents)\n",
|
| 312 |
+
"print(f\"✅ 분할된 문서 청크 개수: {len(split_docs)}\")"
|
| 313 |
+
]
|
| 314 |
+
},
|
| 315 |
+
{
|
| 316 |
+
"cell_type": "code",
|
| 317 |
+
"execution_count": null,
|
| 318 |
+
"metadata": {
|
| 319 |
+
"id": "wAIBhXq4H68s"
|
| 320 |
+
},
|
| 321 |
+
"outputs": [],
|
| 322 |
+
"source": [
|
| 323 |
+
"# 예시: split_docs 리스트에서 앞의 10개 문서만 확인하는 코드\n",
|
| 324 |
+
"\n",
|
| 325 |
+
"# (이전 단계에서 이미 split_docs를 생성했다고 가정)\n",
|
| 326 |
+
"# split_docs = split_documents(documents)\n",
|
| 327 |
+
"\n",
|
| 328 |
+
"# 앞 10개 문서만 출력\n",
|
| 329 |
+
"for idx, doc in enumerate(split_docs[:5], start=1):\n",
|
| 330 |
+
" print(f\"--- 문서 #{idx} ---\")\n",
|
| 331 |
+
" print(f\"파일명 : {doc.metadata.get('filename')}\")\n",
|
| 332 |
+
" print(f\"감정 : {doc.metadata.get('emotion')}\")\n",
|
| 333 |
+
" print(f\"관계 : {doc.metadata.get('relation')}\")\n",
|
| 334 |
+
" print(\"내용 (일부) :\")\n",
|
| 335 |
+
" print(doc.page_content[:200].replace(\"\\n\", \" \") + \"...\\n\")"
|
| 336 |
+
]
|
| 337 |
+
},
|
| 338 |
+
{
|
| 339 |
+
"cell_type": "code",
|
| 340 |
+
"execution_count": null,
|
| 341 |
+
"metadata": {
|
| 342 |
+
"id": "98_fnjppGVhc"
|
| 343 |
+
},
|
| 344 |
+
"outputs": [],
|
| 345 |
+
"source": [
|
| 346 |
+
"# 3) FAISS DB 생성\n",
|
| 347 |
+
"if not split_docs:\n",
|
| 348 |
+
" raise RuntimeError(\"❌ 분할된 문서가 없어서 FAISS 생성이 불가능합니다.\")\n",
|
| 349 |
+
"faiss_db = create_faiss_db(split_docs)\n",
|
| 350 |
+
"print(\"✅ FAISS DB 생성 완료\")"
|
| 351 |
+
]
|
| 352 |
+
},
|
| 353 |
+
{
|
| 354 |
+
"cell_type": "code",
|
| 355 |
+
"execution_count": null,
|
| 356 |
+
"metadata": {
|
| 357 |
+
"id": "rEtEXpKTRQ2_"
|
| 358 |
+
},
|
| 359 |
+
"outputs": [],
|
| 360 |
+
"source": [
|
| 361 |
+
"# 저장/로딩할 FAISS 인덱스 폴더 경로\n",
|
| 362 |
+
"index_dir = \"/content/drive/MyDrive/2025_Bigdata_nlp_class/faiss_index\"\n",
|
| 363 |
+
"\n",
|
| 364 |
+
"# --- (1) 이미 저장된 인덱스가 있는지 확인 ---\n",
|
| 365 |
+
"if os.path.isdir(index_dir) and \\\n",
|
| 366 |
+
" os.path.exists(os.path.join(index_dir, \"index.faiss\")):\n",
|
| 367 |
+
" # 저장된 인덱스가 있으면 로드\n",
|
| 368 |
+
" embeddings = OpenAIEmbeddings()\n",
|
| 369 |
+
" # allow_dangerous_deserialization=True 를 추가하여 로딩을 허용합니다.\n",
|
| 370 |
+
" faiss_db = FAISS.load_local(index_dir, embeddings, allow_dangerous_deserialization=True)\n",
|
| 371 |
+
" print(\"✅ 기존 FAISS 인덱스를 불러왔습니다:\", index_dir)\n",
|
| 372 |
+
"\n",
|
| 373 |
+
"else:\n",
|
| 374 |
+
" # 저장된 인덱스가 없으면 새로 생성\n",
|
| 375 |
+
" # ① 여기에 split_docs를 미리 생성하는 코드를 넣으세요\n",
|
| 376 |
+
" # 예시: split_docs = split_documents(documents)\n",
|
| 377 |
+
" #\n",
|
| 378 |
+
" # ② create_faiss_db 함수나 직접 임베딩 + 저장 로직을 호출\n",
|
| 379 |
+
" #\n",
|
| 380 |
+
" # 예시:\n",
|
| 381 |
+
" # split_docs = split_documents(documents)\n",
|
| 382 |
+
" # embeddings = OpenAIEmbeddings()\n",
|
| 383 |
+
" # faiss_db = FAISS.from_documents(split_docs, embeddings)\n",
|
| 384 |
+
" #\n",
|
| 385 |
+
" # 실제 프로젝트에 맞게 아래 두 줄을 수정하세요:\n",
|
| 386 |
+
" faiss_db = create_faiss_db(split_docs)\n",
|
| 387 |
+
" embeddings = OpenAIEmbeddings()\n",
|
| 388 |
+
"\n",
|
| 389 |
+
" # --- (2) 생성된 FAISS를 로컬에 저장 ---\n",
|
| 390 |
+
" os.makedirs(index_dir, exist_ok=True)\n",
|
| 391 |
+
" faiss_db.save_local(index_dir)\n",
|
| 392 |
+
" print(\"✅ 새로 FAISS 인덱스를 생성하고 저장했습니다:\", index_dir)\n",
|
| 393 |
+
"\n",
|
| 394 |
+
"# 이후 faiss_db를 retriever로 사용 가능합니다.\n",
|
| 395 |
+
"# 예시:\n",
|
| 396 |
+
"retriever = faiss_db.as_retriever()"
|
| 397 |
+
]
|
| 398 |
+
},
|
| 399 |
+
{
|
| 400 |
+
"cell_type": "code",
|
| 401 |
+
"execution_count": null,
|
| 402 |
+
"metadata": {
|
| 403 |
+
"id": "0w4Xzp6FJzD1"
|
| 404 |
+
},
|
| 405 |
+
"outputs": [],
|
| 406 |
+
"source": [
|
| 407 |
+
"# === 4. 사용자 검색 함수 (감정 + 관계 필터) ===\n",
|
| 408 |
+
"def filtered_similarity_search(vectorstore, query, emotion=None, relation=None, k=3):\n",
|
| 409 |
+
" # 필터링\n",
|
| 410 |
+
" all_docs = vectorstore.docstore._dict.values()\n",
|
| 411 |
+
" filtered_docs = [\n",
|
| 412 |
+
" doc for doc in all_docs\n",
|
| 413 |
+
" if (emotion is None or doc.metadata.get(\"emotion\") == emotion)\n",
|
| 414 |
+
" and (relation is None or relation in doc.metadata.get(\"relation\"))\n",
|
| 415 |
+
" ]\n",
|
| 416 |
+
"\n",
|
| 417 |
+
" if not filtered_docs:\n",
|
| 418 |
+
" print(\"❗해당 조건의 문서가 없습니다.\")\n",
|
| 419 |
+
" return []\n",
|
| 420 |
+
"\n",
|
| 421 |
+
" # 유사도 기반 검색\n",
|
| 422 |
+
" splitter = RecursiveCharacterTextSplitter(chunk_size=1000, chunk_overlap=100)\n",
|
| 423 |
+
" query_chunks = splitter.split_text(query)\n",
|
| 424 |
+
" search_results = []\n",
|
| 425 |
+
" for chunk in query_chunks:\n",
|
| 426 |
+
" search_results.extend(vectorstore.similarity_search(chunk, k=k))\n",
|
| 427 |
+
" return search_results"
|
| 428 |
+
]
|
| 429 |
+
},
|
| 430 |
+
{
|
| 431 |
+
"cell_type": "code",
|
| 432 |
+
"execution_count": null,
|
| 433 |
+
"metadata": {
|
| 434 |
+
"id": "wE5IY8l5KH0T"
|
| 435 |
+
},
|
| 436 |
+
"outputs": [],
|
| 437 |
+
"source": [
|
| 438 |
+
"# === 예시 검색 ===\n",
|
| 439 |
+
"query = \"아기를 키우는 게 너무 힘들어요. 조언이 필요해요.\"\n",
|
| 440 |
+
"results = filtered_similarity_search(faiss_db, query, emotion=\"기쁨\", relation=\"부모자녀\")\n",
|
| 441 |
+
"\n",
|
| 442 |
+
"for i, doc in enumerate(results):\n",
|
| 443 |
+
" print(f\"\\n✅ 검색 결과 {i+1}\")\n",
|
| 444 |
+
" print(f\"파일명: {doc.metadata['filename']}\")\n",
|
| 445 |
+
" print(f\"감정: {doc.metadata['emotion']} / 관계: {doc.metadata['relation']}\")\n",
|
| 446 |
+
" print(doc.page_content[:300] + \"...\")"
|
| 447 |
+
]
|
| 448 |
+
},
|
| 449 |
+
{
|
| 450 |
+
"cell_type": "code",
|
| 451 |
+
"execution_count": null,
|
| 452 |
+
"metadata": {
|
| 453 |
+
"id": "Ay3lQq-RK_AE"
|
| 454 |
+
},
|
| 455 |
+
"outputs": [],
|
| 456 |
+
"source": [
|
| 457 |
+
"import openai\n",
|
| 458 |
+
"\n",
|
| 459 |
+
"# OpenAI API 키 설정\n",
|
| 460 |
+
"class config:\n",
|
| 461 |
+
" OPENAI_API_KEY = \"\"\n",
|
| 462 |
+
"\n",
|
| 463 |
+
"openai.api_key = config.OPENAI_API_KEY\n",
|
| 464 |
+
"\n",
|
| 465 |
+
"# ─── 2) GPT-4o(또는 GPT-4o-mini)를 사용해 “가장 적절한” 문서를 고르는 함수 ───────────────────\n",
|
| 466 |
+
"def choose_best_doc_with_gpt(query, docs, model=\"gpt-4o-mini\"):\n",
|
| 467 |
+
" \"\"\"\n",
|
| 468 |
+
" query: 사용자의 원래 질문\n",
|
| 469 |
+
" docs: filtered_similarity_search에서 반환된 Document 객체 리스트\n",
|
| 470 |
+
" model: \"gpt-4o\" 또는 \"gpt-4o-mini\"\n",
|
| 471 |
+
" 반환: (best_doc, gpt_explanation)\n",
|
| 472 |
+
" \"\"\"\n",
|
| 473 |
+
" # (A) 프롬프트 구성\n",
|
| 474 |
+
" prompt_parts = [\n",
|
| 475 |
+
" \"당신은 대화 응답 후보를 평가하는 전문가입니다.\\n\",\n",
|
| 476 |
+
" f\"사용자 질문: \\\"{query}\\\"\\n\",\n",
|
| 477 |
+
" \"다음은 검색된 응답 후보들입니다.\\n\"\n",
|
| 478 |
+
" ]\n",
|
| 479 |
+
"\n",
|
| 480 |
+
" for idx, doc in enumerate(docs, start=1):\n",
|
| 481 |
+
" snippet = doc.page_content.strip().replace(\"\\n\", \" \")\n",
|
| 482 |
+
" if len(snippet) > 300:\n",
|
| 483 |
+
" snippet = snippet[:300] + \"...\"\n",
|
| 484 |
+
" prompt_parts.append(\n",
|
| 485 |
+
" f\"[{idx}]\\n\"\n",
|
| 486 |
+
" f\"Filename: {doc.metadata.get('filename')}\\n\"\n",
|
| 487 |
+
" f\"Emotion: {doc.metadata.get('emotion')}, Relation: {doc.metadata.get('relation')}\\n\"\n",
|
| 488 |
+
" f\"Content: \\\"{snippet}\\\"\\n\"\n",
|
| 489 |
+
" )\n",
|
| 490 |
+
"\n",
|
| 491 |
+
" prompt_parts.append(\n",
|
| 492 |
+
" \"\\n위 후보들 중에서, 사용자 질문에 가장 적절한 응답을 하나 선택하고, 그 이유를 간단히 설명해주세요.\\n\"\n",
|
| 493 |
+
" \"반드시 다음 형식으로 응답해야 합니다:\\n\"\n",
|
| 494 |
+
" \"선택: [번호]\\n\"\n",
|
| 495 |
+
" \"이유: [이유]\\n\"\n",
|
| 496 |
+
" )\n",
|
| 497 |
+
"\n",
|
| 498 |
+
" full_prompt = \"\\n\".join(prompt_parts)\n",
|
| 499 |
+
"\n",
|
| 500 |
+
" # (B) GPT-4o 호출\n",
|
| 501 |
+
" response = openai.chat.completions.create(\n",
|
| 502 |
+
" model=model,\n",
|
| 503 |
+
" messages=[\n",
|
| 504 |
+
" {\"role\": \"system\", \"content\": \"당신은 뛰어난 대화 평가자입니다.\"},\n",
|
| 505 |
+
" {\"role\": \"user\", \"content\": full_prompt}\n",
|
| 506 |
+
" ],\n",
|
| 507 |
+
" max_tokens=300,\n",
|
| 508 |
+
" temperature=0.0\n",
|
| 509 |
+
" )\n",
|
| 510 |
+
"\n",
|
| 511 |
+
" gpt_reply = response.choices[0].message.content.strip()\n",
|
| 512 |
+
"\n",
|
| 513 |
+
" # (C) GPT가 반환한 '선택: [번호]' 파싱\n",
|
| 514 |
+
" selected_idx = None\n",
|
| 515 |
+
" for line in gpt_reply.splitlines():\n",
|
| 516 |
+
" if line.strip().startswith(\"선택\"):\n",
|
| 517 |
+
" import re\n",
|
| 518 |
+
" m = re.search(r\"\\[(\\d+)\\]\", line)\n",
|
| 519 |
+
" if m:\n",
|
| 520 |
+
" selected_idx = int(m.group(1))\n",
|
| 521 |
+
" break\n",
|
| 522 |
+
"\n",
|
| 523 |
+
" # 파싱 실패 시 기본 1번 선택\n",
|
| 524 |
+
" if selected_idx is None or selected_idx < 1 or selected_idx > len(docs):\n",
|
| 525 |
+
" selected_idx = 1\n",
|
| 526 |
+
"\n",
|
| 527 |
+
" best_doc = docs[selected_idx - 1]\n",
|
| 528 |
+
" return best_doc, gpt_reply\n",
|
| 529 |
+
"\n",
|
| 530 |
+
"\n",
|
| 531 |
+
"# ─── 3) 예시 검색 + GPT-4o 최종 선택 ───────────────────────────────────────────────\n",
|
| 532 |
+
"if __name__ == \"__main__\":\n",
|
| 533 |
+
" # (가정) faiss_db는 이미 생성되어 로드된 FAISS 인덱스 객체입니다.\n",
|
| 534 |
+
" # 예: faiss_db = FAISS.load_local(index_dir, OpenAIEmbeddings())\n",
|
| 535 |
+
"\n",
|
| 536 |
+
" # ① 조회할 사용자 질의\n",
|
| 537 |
+
" query = \"아기를 키우는 게 너무 힘들어요. 조언이 필요해요.\"\n",
|
| 538 |
+
"\n",
|
| 539 |
+
" # ② 기존 검색 함수 그대로 사용\n",
|
| 540 |
+
" results = filtered_similarity_search(faiss_db, query, emotion=\"기쁨\", relation=\"부모자녀\")\n",
|
| 541 |
+
"\n",
|
| 542 |
+
" # ③ 검색 결과 출력\n",
|
| 543 |
+
" for i, doc in enumerate(results, start=1):\n",
|
| 544 |
+
" print(f\"\\n✅ 검색 결과 {i}\")\n",
|
| 545 |
+
" print(f\"파일명: {doc.metadata['filename']}\")\n",
|
| 546 |
+
" print(f\"감정: {doc.metadata['emotion']} / 관계: {doc.metadata['relation']}\")\n",
|
| 547 |
+
" print(doc.page_content[:300] + \"...\\n\")\n",
|
| 548 |
+
"\n",
|
| 549 |
+
" # ④ 검색 결과가 있으면, GPT-4o로 \"가장 적절한\" 문서 선택\n",
|
| 550 |
+
" if results:\n",
|
| 551 |
+
" best_doc, explanation = choose_best_doc_with_gpt(query, results, model=\"gpt-4o-mini\")\n",
|
| 552 |
+
"\n",
|
| 553 |
+
" print(\"\\n\\n=== GPT-4o가 선택한 최종 응답 ===\")\n",
|
| 554 |
+
" print(f\"■ 선택된 파일명: {best_doc.metadata['filename']}\")\n",
|
| 555 |
+
" print(f\"■ 선택 이유:\\n{explanation}\\n\")\n",
|
| 556 |
+
" print(f\"■ 최종 응답 내용:\\n{best_doc.page_content}\")\n",
|
| 557 |
+
" else:\n",
|
| 558 |
+
" print(\"❗ 검색 결과가 없어 GPT 평가를 진행할 수 없습니다.\")\n"
|
| 559 |
+
]
|
| 560 |
+
},
|
| 561 |
+
{
|
| 562 |
+
"cell_type": "code",
|
| 563 |
+
"execution_count": null,
|
| 564 |
+
"metadata": {
|
| 565 |
+
"id": "7hNWJkaTSOC9"
|
| 566 |
+
},
|
| 567 |
+
"outputs": [],
|
| 568 |
+
"source": [
|
| 569 |
+
"import os\n",
|
| 570 |
+
"import openai\n",
|
| 571 |
+
"import unicodedata\n",
|
| 572 |
+
"from langchain.schema import Document\n",
|
| 573 |
+
"from langchain.text_splitter import RecursiveCharacterTextSplitter\n",
|
| 574 |
+
"from langchain.embeddings.openai import OpenAIEmbeddings\n",
|
| 575 |
+
"from langchain.vectorstores import FAISS\n",
|
| 576 |
+
"\n",
|
| 577 |
+
"# ─── 0) OpenAI API 키 설정 ─────────────────────────────────────────────────\n",
|
| 578 |
+
"openai.api_key = os.getenv(\"OPENAI_API_KEY\")\n",
|
| 579 |
+
"\n",
|
| 580 |
+
"\n",
|
| 581 |
+
"# ─── 1) 사용자 검색 함수 그대로 ────────────────────────────────────────────────\n",
|
| 582 |
+
"def filtered_similarity_search(vectorstore, query, emotion=None, relation=None, k=3):\n",
|
| 583 |
+
" all_docs = vectorstore.docstore._dict.values()\n",
|
| 584 |
+
" filtered_docs = [\n",
|
| 585 |
+
" doc for doc in all_docs\n",
|
| 586 |
+
" if (emotion is None or doc.metadata.get(\"emotion\") == emotion)\n",
|
| 587 |
+
" and (relation is None or relation in doc.metadata.get(\"relation\"))\n",
|
| 588 |
+
" ]\n",
|
| 589 |
+
"\n",
|
| 590 |
+
" if not filtered_docs:\n",
|
| 591 |
+
" print(\"❗ 해당 조건의 문서가 없습니다.\")\n",
|
| 592 |
+
" return []\n",
|
| 593 |
+
"\n",
|
| 594 |
+
" splitter = RecursiveCharacterTextSplitter(chunk_size=1000, chunk_overlap=100)\n",
|
| 595 |
+
" query_chunks = splitter.split_text(query)\n",
|
| 596 |
+
"\n",
|
| 597 |
+
" search_results = []\n",
|
| 598 |
+
" for chunk in query_chunks:\n",
|
| 599 |
+
" search_results.extend(vectorstore.similarity_search(chunk, k=k))\n",
|
| 600 |
+
" return search_results\n",
|
| 601 |
+
"\n",
|
| 602 |
+
"\n",
|
| 603 |
+
"# ─── 2) GPT-4o로 “최적 후보 선택 + 이유 설명” 함수 (v1 API) ───────────────────────────\n",
|
| 604 |
+
"def choose_best_doc_with_gpt(query, docs, model=\"gpt-4o-mini\"):\n",
|
| 605 |
+
" \"\"\"\n",
|
| 606 |
+
" query: 사용자의 원래 질문\n",
|
| 607 |
+
" docs: filtered_similarity_search에서 반환된 Document 리스트\n",
|
| 608 |
+
" model: \"gpt-4o\" 또는 \"gpt-4o-mini\"\n",
|
| 609 |
+
" 반환: (best_doc, gpt_reason)\n",
|
| 610 |
+
" - best_doc: GPT가 선택한 Document 객체\n",
|
| 611 |
+
" - gpt_reason: \"선택: [번호]\\n이유: ...\" 형태의 문자열\n",
|
| 612 |
+
" \"\"\"\n",
|
| 613 |
+
" prompt_parts = [\n",
|
| 614 |
+
" \"당신은 후보 응답을 평가하는 전문가입니다.\\n\",\n",
|
| 615 |
+
" f\"사용자 질문: \\\"{query}\\\"\\n\",\n",
|
| 616 |
+
" \"다음은 검색된 응답 후보들입니다.\\n\"\n",
|
| 617 |
+
" ]\n",
|
| 618 |
+
"\n",
|
| 619 |
+
" for idx, doc in enumerate(docs, start=1):\n",
|
| 620 |
+
" snippet = doc.page_content.strip().replace(\"\\n\", \" \")\n",
|
| 621 |
+
" if len(snippet) > 300:\n",
|
| 622 |
+
" snippet = snippet[:300] + \"...\"\n",
|
| 623 |
+
" prompt_parts.append(\n",
|
| 624 |
+
" f\"[{idx}]\\n\"\n",
|
| 625 |
+
" f\"Filename: {doc.metadata.get('filename')}\\n\"\n",
|
| 626 |
+
" f\"Emotion: {doc.metadata.get('emotion')}, Relation: {doc.metadata.get('relation')}\\n\"\n",
|
| 627 |
+
" f\"Content: \\\"{snippet}\\\"\\n\"\n",
|
| 628 |
+
" )\n",
|
| 629 |
+
"\n",
|
| 630 |
+
" prompt_parts.append(\n",
|
| 631 |
+
" \"\\n위 후보들 중에서, 사용자 질문에 가장 적절한 응답을 한 개만 선택하고, 그 이유를 간단히 설명해주세요.\\n\"\n",
|
| 632 |
+
" \"반드시 다음 형식으로 응답해 주세요:\\n\"\n",
|
| 633 |
+
" \"선택: [번호]\\n\"\n",
|
| 634 |
+
" \"이유: [간단한 설명]\\n\"\n",
|
| 635 |
+
" )\n",
|
| 636 |
+
"\n",
|
| 637 |
+
" full_prompt = \"\\n\".join(prompt_parts)\n",
|
| 638 |
+
"\n",
|
| 639 |
+
" # ── OpenAI Chat Completions 호출 ─────────────────────────────────────────────\n",
|
| 640 |
+
" response = openai.chat.completions.create(\n",
|
| 641 |
+
" model=model,\n",
|
| 642 |
+
" messages=[\n",
|
| 643 |
+
" {\"role\": \"system\", \"content\": \"당신은 뛰어난 대화 평가자입니다.\"},\n",
|
| 644 |
+
" {\"role\": \"user\", \"content\": full_prompt}\n",
|
| 645 |
+
" ],\n",
|
| 646 |
+
" max_tokens=300,\n",
|
| 647 |
+
" temperature=0.0\n",
|
| 648 |
+
" )\n",
|
| 649 |
+
"\n",
|
| 650 |
+
" gpt_reply = response.choices[0].message.content.strip()\n",
|
| 651 |
+
"\n",
|
| 652 |
+
" # “선택: [번호]” 파싱\n",
|
| 653 |
+
" selected_idx = None\n",
|
| 654 |
+
" for line in gpt_reply.splitlines():\n",
|
| 655 |
+
" if line.strip().startswith(\"선택\"):\n",
|
| 656 |
+
" import re\n",
|
| 657 |
+
" m = re.search(r\"\\[(\\d+)\\]\", line)\n",
|
| 658 |
+
" if m:\n",
|
| 659 |
+
" selected_idx = int(m.group(1))\n",
|
| 660 |
+
" break\n",
|
| 661 |
+
"\n",
|
| 662 |
+
" # 파싱 실패 시 기본 1번\n",
|
| 663 |
+
" if selected_idx is None or selected_idx < 1 or selected_idx > len(docs):\n",
|
| 664 |
+
" selected_idx = 1\n",
|
| 665 |
+
"\n",
|
| 666 |
+
" best_doc = docs[selected_idx - 1]\n",
|
| 667 |
+
" return best_doc, gpt_reply\n",
|
| 668 |
+
"\n",
|
| 669 |
+
"\n",
|
| 670 |
+
"# ─── 3) “선택된 후보를 간결하게 재작성” 함수 ─────────────────────────────────────\n",
|
| 671 |
+
"def generate_final_answer(query, best_doc, model=\"gpt-4o-mini\"):\n",
|
| 672 |
+
" \"\"\"\n",
|
| 673 |
+
" query: 사용자의 원래 질문\n",
|
| 674 |
+
" best_doc: choose_best_doc_with_gpt가 반환한 Document 객체\n",
|
| 675 |
+
" model: \"gpt-4o\" 또는 \"gpt-4o-mini\"\n",
|
| 676 |
+
" 반환: GPT가 생성한 최종 사용자용 응답(불필요한 부분 제거된 형태)\n",
|
| 677 |
+
" \"\"\"\n",
|
| 678 |
+
" # (A) 프롬프트 구성: “최종 응답 후보”를 직접 재작성하도록 요청\n",
|
| 679 |
+
" prompt = (\n",
|
| 680 |
+
" \"다음은 사용자의 질문과, 선택된 최적 응답 후보입니다.\\n\\n\"\n",
|
| 681 |
+
" f\"사용자 질문: \\\"{query}\\\"\\n\"\n",
|
| 682 |
+
" \"선택된 후보 응답 내용(원문):\\n\"\n",
|
| 683 |
+
" f\"\\\"\\\"\\\"\\n{best_doc.page_content}\\n\\\"\\\"\\\"\\n\\n\"\n",
|
| 684 |
+
" \"위 원문에서, 불필요한 반복/인사말/개인정보 등은 모두 제거하고, \"\n",
|
| 685 |
+
" \"사용자가 이해하기 쉽도록 핵심만 남겨 간결하게 재작성해주세요.\\n\"\n",
|
| 686 |
+
" \"문체는 친절하고 공감 가득한 톤을 유지해 주시고, \"\n",
|
| 687 |
+
" \"최종 답변만 출력해 주세요.\"\n",
|
| 688 |
+
" )\n",
|
| 689 |
+
"\n",
|
| 690 |
+
" # (B) GPT-4o 호출\n",
|
| 691 |
+
" response = openai.chat.completions.create(\n",
|
| 692 |
+
" model=model,\n",
|
| 693 |
+
" messages=[\n",
|
| 694 |
+
" {\"role\": \"system\", \"content\": \"당신은 친절하고 공감능력이 뛰어난 상담사입니다.\"},\n",
|
| 695 |
+
" {\"role\": \"user\", \"content\": prompt}\n",
|
| 696 |
+
" ],\n",
|
| 697 |
+
" max_tokens=300,\n",
|
| 698 |
+
" temperature=0.7\n",
|
| 699 |
+
" )\n",
|
| 700 |
+
"\n",
|
| 701 |
+
" final_answer = response.choices[0].message.content.strip()\n",
|
| 702 |
+
" return final_answer\n",
|
| 703 |
+
"\n",
|
| 704 |
+
"\n",
|
| 705 |
+
"# ─── 4) 전체 흐름 예시 ────────────────────────────────────────────────────────\n",
|
| 706 |
+
"if __name__ == \"__main__\":\n",
|
| 707 |
+
" # (가정) faiss_db는 이미 생성/로드된 FAISS 인덱스 객체입니다.\n",
|
| 708 |
+
" # 예: faiss_db = FAISS.load_local(index_dir, OpenAIEmbeddings())\n",
|
| 709 |
+
"\n",
|
| 710 |
+
" # ① 사용자 질의\n",
|
| 711 |
+
" query = \"아기를 키우는 게 너무 힘들어요. 조언이 필요해요.\"\n",
|
| 712 |
+
"\n",
|
| 713 |
+
" # ② 기존 검색 함수 그대로 사용\n",
|
| 714 |
+
" results = filtered_similarity_search(faiss_db, query, emotion=\"기쁨\", relation=\"부모자녀\")\n",
|
| 715 |
+
"\n",
|
| 716 |
+
" # ③ 검색 결과 출력 (원본 후보 3개)\n",
|
| 717 |
+
" for i, doc in enumerate(results, start=1):\n",
|
| 718 |
+
" print(f\"\\n✅ 검색 결과 {i}\")\n",
|
| 719 |
+
" print(f\"파일명: {doc.metadata['filename']}\")\n",
|
| 720 |
+
" print(f\"감정: {doc.metadata['emotion']} / 관계: {doc.metadata['relation']}\")\n",
|
| 721 |
+
" print(doc.page_content[:300] + \"...\\n\")\n",
|
| 722 |
+
"\n",
|
| 723 |
+
" # ④ 검색 결과가 있으면, GPT-4o로 “가장 적절한” 문서 선택 + 이유 얻기\n",
|
| 724 |
+
" if results:\n",
|
| 725 |
+
" best_doc, explanation = choose_best_doc_with_gpt(query, results, model=\"gpt-4o-mini\")\n",
|
| 726 |
+
" print(\"\\n\\n=== GPT-4o가 선택한 최종 응답 후보 ===\")\n",
|
| 727 |
+
" print(f\"■ 선택된 파일명: {best_doc.metadata['filename']}\")\n",
|
| 728 |
+
" print(f\"■ 선택 이유:\\n{explanation}\\n\")\n",
|
| 729 |
+
"\n",
|
| 730 |
+
" # ⑤ 선택된 후보를 재작성하여 최종 답변 생성\n",
|
| 731 |
+
" cleaned_answer = generate_final_answer(query, best_doc, model=\"gpt-4o-mini\")\n",
|
| 732 |
+
" print(\"=== 최종 사용자 응답 (불필요한 내용 제거됨) ===\")\n",
|
| 733 |
+
" print(cleaned_answer)\n",
|
| 734 |
+
"\n",
|
| 735 |
+
" else:\n",
|
| 736 |
+
" print(\"❗ 검색 결과가 없어 GPT 평가를 진행할 수 없습니다.\")\n"
|
| 737 |
+
]
|
| 738 |
+
},
|
| 739 |
+
{
|
| 740 |
+
"cell_type": "code",
|
| 741 |
+
"execution_count": null,
|
| 742 |
+
"metadata": {
|
| 743 |
+
"id": "Pk55HkBQSW_s"
|
| 744 |
+
},
|
| 745 |
+
"outputs": [],
|
| 746 |
+
"source": [
|
| 747 |
+
"# ─── 예시: 여러 개의 query를 한 번에 처리해 보는 코드 ─────────────────────\n",
|
| 748 |
+
"\n",
|
| 749 |
+
"# (가정) 이미 아래 함수들이 정의되어 있고, faiss_db도 생성/로드되어 있다고 봅니다.\n",
|
| 750 |
+
"# - filtered_similarity_search(vectorstore, query, emotion, relation)\n",
|
| 751 |
+
"# - choose_best_doc_with_gpt(query, docs, model)\n",
|
| 752 |
+
"# - generate_final_answer(query, best_doc, model)\n",
|
| 753 |
+
"\n",
|
| 754 |
+
"# 1) 테스트할 질의 리스트를 정의\n",
|
| 755 |
+
"queries = [\n",
|
| 756 |
+
" \"아기를 키우는 일을 시작하려는데, 어떻게 준비해야 할까요?\",\n",
|
| 757 |
+
" \"아이가 자꾸 밤에 깨서 낮잠도 잘 못 자요. 어떻게 도와줄 수 있을까요?\",\n",
|
| 758 |
+
" \"육아 스트레스를 푸는 방법이 있을까요?\",\n",
|
| 759 |
+
" \"첫 돌 지난 아기가 말을 잘 안 들을 때 어떻게 해야 하나요?\",\n",
|
| 760 |
+
" \"아기가 갑자기 울음을 멈추지 않아서 당황스러워요. 조언 부탁드려요.\"\n",
|
| 761 |
+
"]\n",
|
| 762 |
+
"\n",
|
| 763 |
+
"# 2) 감정(emotion)과 관계(relation) 예시는 고정해도 되고,\n",
|
| 764 |
+
"# 아니면 query별로 달리 지정해도 됩니다. 여기서는 예시로 전부 \"기쁨\"/\"부모자녀\"로 가정.\n",
|
| 765 |
+
"emotion = \"기쁨\"\n",
|
| 766 |
+
"relation = \"부모자녀\"\n",
|
| 767 |
+
"\n",
|
| 768 |
+
"# 3) 각 query 순회하면서 단계별로 결과 출력\n",
|
| 769 |
+
"for idx, q in enumerate(queries, start=1):\n",
|
| 770 |
+
" print(f\"\\n\\n========== Query #{idx} ==========\")\n",
|
| 771 |
+
" print(f\"사용자 질문: {q}\\n\")\n",
|
| 772 |
+
"\n",
|
| 773 |
+
" # 3-1) 감정/관계 필터 + FAISS 유사도 검색 → 후보 3개 가져오기\n",
|
| 774 |
+
" candidates = filtered_similarity_search(faiss_db, q, emotion=emotion, relation=relation)\n",
|
| 775 |
+
" if not candidates:\n",
|
| 776 |
+
" print(\"❗ 조건에 맞는 문서가 없습니다. 다음 질의로 넘어갑니다.\")\n",
|
| 777 |
+
" continue\n",
|
| 778 |
+
"\n",
|
| 779 |
+
" # 3-2) 후보 원문 간단 출력\n",
|
| 780 |
+
" print(\"■ 검색된 후보 (최대 3개):\")\n",
|
| 781 |
+
" for i, doc in enumerate(candidates, start=1):\n",
|
| 782 |
+
" print(f\"\\n[후보 {i}] 파일명: {doc.metadata['filename']}\")\n",
|
| 783 |
+
" print(f\"감정: {doc.metadata['emotion']}, 관계: {doc.metadata['relation']}\")\n",
|
| 784 |
+
" print(doc.page_content[:200].replace(\"\\n\", \" \") + \"...\\n\")\n",
|
| 785 |
+
"\n",
|
| 786 |
+
" # 3-3) GPT-4o에게 “가장 적절한 후보 선택 + 이유” 요청\n",
|
| 787 |
+
" best_doc, choice_reason = choose_best_doc_with_gpt(q, candidates, model=\"gpt-4o-mini\")\n",
|
| 788 |
+
" print(\"\\n■ GPT-4o가 선택한 후보:\")\n",
|
| 789 |
+
" print(f\" • 선택된 파일명: {best_doc.metadata['filename']}\")\n",
|
| 790 |
+
" print(f\" • 선택 이유:\\n{choice_reason}\\n\")\n",
|
| 791 |
+
"\n",
|
| 792 |
+
" # 3-4) 선택된 후보를 다듬어서 최종 답변 생성\n",
|
| 793 |
+
" final_answer = generate_final_answer(q, best_doc, model=\"gpt-4o-mini\")\n",
|
| 794 |
+
" print(\"■ 최종 사용자 응답 (정제된 텍스트):\")\n",
|
| 795 |
+
" print(final_answer)\n"
|
| 796 |
+
]
|
| 797 |
+
},
|
| 798 |
+
{
|
| 799 |
+
"cell_type": "code",
|
| 800 |
+
"execution_count": null,
|
| 801 |
+
"metadata": {
|
| 802 |
+
"id": "UJAUt5feYme_"
|
| 803 |
+
},
|
| 804 |
+
"outputs": [],
|
| 805 |
+
"source": [
|
| 806 |
+
"!pip install gradio"
|
| 807 |
+
]
|
| 808 |
+
},
|
| 809 |
+
{
|
| 810 |
+
"cell_type": "code",
|
| 811 |
+
"execution_count": null,
|
| 812 |
+
"metadata": {
|
| 813 |
+
"id": "DDPtd5xgYxfn"
|
| 814 |
+
},
|
| 815 |
+
"outputs": [],
|
| 816 |
+
"source": [
|
| 817 |
+
"import gradio as gr\n",
|
| 818 |
+
"import os\n",
|
| 819 |
+
"import openai\n",
|
| 820 |
+
"import unicodedata\n",
|
| 821 |
+
"import json\n",
|
| 822 |
+
"from langchain.schema import Document\n",
|
| 823 |
+
"from langchain.text_splitter import RecursiveCharacterTextSplitter\n",
|
| 824 |
+
"from langchain.embeddings.openai import OpenAIEmbeddings\n",
|
| 825 |
+
"from langchain.vectorstores import FAISS\n",
|
| 826 |
+
"\n",
|
| 827 |
+
"# ─── 0) OpenAI API 키 설정 ─────────────────────────────────────────────────\n",
|
| 828 |
+
"openai.api_key = os.getenv(\"OPENAI_API_KEY\")\n",
|
| 829 |
+
"\n",
|
| 830 |
+
"# ─── 1) JSON 로드 + 메타데이터 추출 함수 ─────────────────────────────────────────\n",
|
| 831 |
+
"def load_documents_with_metadata(folder_path):\n",
|
| 832 |
+
" documents = []\n",
|
| 833 |
+
" for raw_filename in os.listdir(folder_path):\n",
|
| 834 |
+
" filename = unicodedata.normalize(\"NFC\", raw_filename)\n",
|
| 835 |
+
" file_path = os.path.join(folder_path, raw_filename)\n",
|
| 836 |
+
"\n",
|
| 837 |
+
" if not os.path.isfile(file_path):\n",
|
| 838 |
+
" continue\n",
|
| 839 |
+
" if not filename.endswith(\".json\"):\n",
|
| 840 |
+
" continue\n",
|
| 841 |
+
"\n",
|
| 842 |
+
" try:\n",
|
| 843 |
+
" parts = filename.replace(\".json\", \"\").split(\"_\")\n",
|
| 844 |
+
" emotion = parts[1] if len(parts) > 1 else \"unknown\"\n",
|
| 845 |
+
" relation = parts[2] if len(parts) > 2 else \"unknown\"\n",
|
| 846 |
+
"\n",
|
| 847 |
+
" with open(file_path, \"r\", encoding=\"utf-8\") as f:\n",
|
| 848 |
+
" data = json.load(f)\n",
|
| 849 |
+
" utterances = data.get(\"utterances\", [])\n",
|
| 850 |
+
" full_text = \"\\n\".join([utt.get(\"text\",\"\") for utt in utterances])\n",
|
| 851 |
+
" if full_text.strip() == \"\":\n",
|
| 852 |
+
" continue\n",
|
| 853 |
+
"\n",
|
| 854 |
+
" doc = Document(\n",
|
| 855 |
+
" page_content=full_text,\n",
|
| 856 |
+
" metadata={\"filename\": filename, \"emotion\": emotion, \"relation\": relation}\n",
|
| 857 |
+
" )\n",
|
| 858 |
+
" documents.append(doc)\n",
|
| 859 |
+
" except Exception as e:\n",
|
| 860 |
+
" print(f\"❌ 오류 발생 ({filename}): {e}\")\n",
|
| 861 |
+
"\n",
|
| 862 |
+
" return documents\n",
|
| 863 |
+
"\n",
|
| 864 |
+
"# ─── 2) 문서 분할 함수 ─────────────────────────────────────────────────────\n",
|
| 865 |
+
"def split_documents(documents):\n",
|
| 866 |
+
" splitter = RecursiveCharacterTextSplitter(chunk_size=300, chunk_overlap=50)\n",
|
| 867 |
+
" return splitter.split_documents(documents)\n",
|
| 868 |
+
"\n",
|
| 869 |
+
"# ─── 3) FAISS 인덱스 생성 혹은 로드 함수 ───────────────────────────────────\n",
|
| 870 |
+
"def create_or_load_faiss(index_dir, split_docs):\n",
|
| 871 |
+
" embeddings = OpenAIEmbeddings()\n",
|
| 872 |
+
" if os.path.isdir(index_dir) and os.path.exists(os.path.join(index_dir, \"index.faiss\")):\n",
|
| 873 |
+
" faiss_db = FAISS.load_local(index_dir, embeddings, allow_dangerous_deserialization=True)\n",
|
| 874 |
+
" print(\"✅ 기존 FAISS 인덱스를 로드했습니다.\")\n",
|
| 875 |
+
" else:\n",
|
| 876 |
+
" faiss_db = FAISS.from_documents(split_docs, embeddings)\n",
|
| 877 |
+
" os.makedirs(index_dir, exist_ok=True)\n",
|
| 878 |
+
" faiss_db.save_local(index_dir)\n",
|
| 879 |
+
" print(\"✅ 새로운 FAISS 인덱스를 생성하고 저장했습니다.\")\n",
|
| 880 |
+
" return faiss_db\n",
|
| 881 |
+
"\n",
|
| 882 |
+
"# ─── 4) 필터 + 유사도 검색 함수 ────────────────────────────────────────────────\n",
|
| 883 |
+
"def filtered_similarity_search(vectorstore, query, emotion=None, relation=None, k=3):\n",
|
| 884 |
+
" all_docs = vectorstore.docstore._dict.values()\n",
|
| 885 |
+
" filtered_docs = [\n",
|
| 886 |
+
" doc for doc in all_docs\n",
|
| 887 |
+
" if (emotion is None or doc.metadata.get(\"emotion\") == emotion)\n",
|
| 888 |
+
" and (relation is None or relation in doc.metadata.get(\"relation\"))\n",
|
| 889 |
+
" ]\n",
|
| 890 |
+
"\n",
|
| 891 |
+
" if not filtered_docs:\n",
|
| 892 |
+
" return []\n",
|
| 893 |
+
"\n",
|
| 894 |
+
" splitter = RecursiveCharacterTextSplitter(chunk_size=1000, chunk_overlap=100)\n",
|
| 895 |
+
" query_chunks = splitter.split_text(query)\n",
|
| 896 |
+
"\n",
|
| 897 |
+
" search_results = []\n",
|
| 898 |
+
" for chunk in query_chunks:\n",
|
| 899 |
+
" search_results.extend(vectorstore.similarity_search(chunk, k=k))\n",
|
| 900 |
+
" return search_results\n",
|
| 901 |
+
"\n",
|
| 902 |
+
"# ─── 5) 후보 중 최고 문서 선택 함수 ─────────────────────────────────────────────\n",
|
| 903 |
+
"def choose_best_doc_with_gpt(query, docs, model=\"gpt-4o-mini\"):\n",
|
| 904 |
+
" prompt_parts = [\n",
|
| 905 |
+
" \"당신은 대화 응답 후보를 평가하는 전문가입니다.\\n\",\n",
|
| 906 |
+
" f\"사용자 질문: \\\"{query}\\\"\\n\",\n",
|
| 907 |
+
" \"다음은 검색된 응답 후보들입니다.\\n\"\n",
|
| 908 |
+
" ]\n",
|
| 909 |
+
"\n",
|
| 910 |
+
" for idx, doc in enumerate(docs, start=1):\n",
|
| 911 |
+
" snippet = doc.page_content.strip().replace(\"\\n\", \" \")\n",
|
| 912 |
+
" if len(snippet) > 300:\n",
|
| 913 |
+
" snippet = snippet[:300] + \"...\"\n",
|
| 914 |
+
" prompt_parts.append(\n",
|
| 915 |
+
" f\"[{idx}]\\n\"\n",
|
| 916 |
+
" f\"Filename: {doc.metadata.get('filename')}\\n\"\n",
|
| 917 |
+
" f\"Emotion: {doc.metadata.get('emotion')}, Relation: {doc.metadata.get('relation')}\\n\"\n",
|
| 918 |
+
" f\"Content: \\\"{snippet}\\\"\\n\"\n",
|
| 919 |
+
" )\n",
|
| 920 |
+
"\n",
|
| 921 |
+
" prompt_parts.append(\n",
|
| 922 |
+
" \"\\n위 후보들 중에서, 사용자 질문에 가장 적절한 응답을 하나 선택하고, 그 이유를 간단히 설명해주세요.\\n\"\n",
|
| 923 |
+
" \"반드시 다음 형식으로 응답해 주세요:\\n\"\n",
|
| 924 |
+
" \"선택: [번호]\\n\"\n",
|
| 925 |
+
" \"이유: [간단한 설명]\\n\"\n",
|
| 926 |
+
" )\n",
|
| 927 |
+
"\n",
|
| 928 |
+
" full_prompt = \"\\n\".join(prompt_parts)\n",
|
| 929 |
+
"\n",
|
| 930 |
+
" response = openai.chat.completions.create(\n",
|
| 931 |
+
" model=model,\n",
|
| 932 |
+
" messages=[\n",
|
| 933 |
+
" {\"role\": \"system\", \"content\": \"당신은 뛰어난 대화 평가자입니다.\"},\n",
|
| 934 |
+
" {\"role\": \"user\", \"content\": full_prompt}\n",
|
| 935 |
+
" ],\n",
|
| 936 |
+
" max_tokens=300,\n",
|
| 937 |
+
" temperature=0.0\n",
|
| 938 |
+
" )\n",
|
| 939 |
+
"\n",
|
| 940 |
+
" gpt_reply = response.choices[0].message.content.strip()\n",
|
| 941 |
+
" selected_idx = None\n",
|
| 942 |
+
" for line in gpt_reply.splitlines():\n",
|
| 943 |
+
" if line.strip().startswith(\"선택\"):\n",
|
| 944 |
+
" import re\n",
|
| 945 |
+
" m = re.search(r\"\\[(\\d+)\\]\", line)\n",
|
| 946 |
+
" if m:\n",
|
| 947 |
+
" selected_idx = int(m.group(1))\n",
|
| 948 |
+
" break\n",
|
| 949 |
+
"\n",
|
| 950 |
+
" if selected_idx is None or selected_idx < 1 or selected_idx > len(docs):\n",
|
| 951 |
+
" selected_idx = 1\n",
|
| 952 |
+
"\n",
|
| 953 |
+
" best_doc = docs[selected_idx - 1]\n",
|
| 954 |
+
" return best_doc, gpt_reply\n",
|
| 955 |
+
"\n",
|
| 956 |
+
"# ─── 6) 최종 답변 간결하게 생성 함수 ─────────────────────────────────────────────\n",
|
| 957 |
+
"def generate_final_answer(query, best_doc, model=\"gpt-4o-mini\"):\n",
|
| 958 |
+
" prompt = (\n",
|
| 959 |
+
" \"다음은 사용자의 질문과, 선택된 최적 응답 후보입니다.\\n\\n\"\n",
|
| 960 |
+
" f\"사용자 질문: \\\"{query}\\\"\\n\"\n",
|
| 961 |
+
" \"선택된 후보 응답 내용(원문):\\n\"\n",
|
| 962 |
+
" f\"\\\"\\\"\\\"\\n{best_doc.page_content}\\n\\\"\\\"\\\"\\n\\n\"\n",
|
| 963 |
+
" \"위 원문에서, 불필요한 반복/인사말/개인정보 등은 모두 제거하고, \"\n",
|
| 964 |
+
" \"사용자가 이해하기 쉽도록 핵심만 남겨 간결하게 재작성해주세요.\\n\"\n",
|
| 965 |
+
" \"문체는 친절하고 공감 가득한 톤을 유지해 주시고, \"\n",
|
| 966 |
+
" \"최종 답변만 출력해 주세요.\"\n",
|
| 967 |
+
" )\n",
|
| 968 |
+
"\n",
|
| 969 |
+
" response = openai.chat.completions.create(\n",
|
| 970 |
+
" model=model,\n",
|
| 971 |
+
" messages=[\n",
|
| 972 |
+
" {\"role\": \"system\", \"content\": \"당신은 친절하고 공감능력이 뛰어난 상담사입니다.\"},\n",
|
| 973 |
+
" {\"role\": \"user\", \"content\": prompt}\n",
|
| 974 |
+
" ],\n",
|
| 975 |
+
" max_tokens=300,\n",
|
| 976 |
+
" temperature=0.7\n",
|
| 977 |
+
" )\n",
|
| 978 |
+
"\n",
|
| 979 |
+
" final_answer = response.choices[0].message.content.strip()\n",
|
| 980 |
+
" return final_answer\n",
|
| 981 |
+
"\n",
|
| 982 |
+
"# ─── 7) Gradio 응용: 채팅 인터페이스 구축 ───────────────────────────────────────\n",
|
| 983 |
+
"index_dir = \"/content/drive/MyDrive/2025_Bigdata_nlp_class/faiss_index\"\n",
|
| 984 |
+
"folder_path = \"/content/drive/MyDrive/2025_Bigdata_nlp_class/aihub_dataset/Training/02_label_data\"\n",
|
| 985 |
+
"\n",
|
| 986 |
+
"# 문서 로드 및 FAISS 초기화\n",
|
| 987 |
+
"documents = load_documents_with_metadata(folder_path)\n",
|
| 988 |
+
"split_docs = split_documents(documents)\n",
|
| 989 |
+
"faiss_db = create_or_load_faiss(index_dir, split_docs)\n",
|
| 990 |
+
"\n",
|
| 991 |
+
"def chat_response(query, emotion, relation):\n",
|
| 992 |
+
" candidates = filtered_similarity_search(faiss_db, query, emotion, relation)\n",
|
| 993 |
+
" if not candidates:\n",
|
| 994 |
+
" return \"조건에 맞는 문서가 없습니다.\"\n",
|
| 995 |
+
"\n",
|
| 996 |
+
" best_doc, _ = choose_best_doc_with_gpt(query, candidates, model=\"gpt-4o-mini\")\n",
|
| 997 |
+
" final_answer = generate_final_answer(query, best_doc, model=\"gpt-4o-mini\")\n",
|
| 998 |
+
" return final_answer\n",
|
| 999 |
+
"\n",
|
| 1000 |
+
"with gr.Blocks() as demo:\n",
|
| 1001 |
+
" gr.Markdown(\"## 감정/관계 기반 Empathy QA 시스템\")\n",
|
| 1002 |
+
" with gr.Row():\n",
|
| 1003 |
+
" txt_query = gr.Textbox(label=\"질문\", placeholder=\"질문을 입력하세요...\", lines=2)\n",
|
| 1004 |
+
" with gr.Row():\n",
|
| 1005 |
+
" txt_emotion = gr.Textbox(label=\"Emotion (예: 기쁨, 당황, 분노)\", placeholder=\"ex) 기쁨\")\n",
|
| 1006 |
+
" txt_relation = gr.Textbox(label=\"Relation (예: 부모자녀, 부부, 연인)\", placeholder=\"ex) 부모자녀\")\n",
|
| 1007 |
+
" btn_submit = gr.Button(\"전송\")\n",
|
| 1008 |
+
" output = gr.Textbox(label=\"답변\", lines=5)\n",
|
| 1009 |
+
"\n",
|
| 1010 |
+
" btn_submit.click(chat_response, inputs=[txt_query, txt_emotion, txt_relation], outputs=output)\n",
|
| 1011 |
+
"\n",
|
| 1012 |
+
"demo.launch()\n"
|
| 1013 |
+
]
|
| 1014 |
+
}
|
| 1015 |
+
],
|
| 1016 |
+
"metadata": {
|
| 1017 |
+
"colab": {
|
| 1018 |
+
"private_outputs": true,
|
| 1019 |
+
"provenance": []
|
| 1020 |
+
},
|
| 1021 |
+
"kernelspec": {
|
| 1022 |
+
"display_name": "Python 3",
|
| 1023 |
+
"name": "python3"
|
| 1024 |
+
},
|
| 1025 |
+
"language_info": {
|
| 1026 |
+
"name": "python"
|
| 1027 |
+
}
|
| 1028 |
+
},
|
| 1029 |
+
"nbformat": 4,
|
| 1030 |
+
"nbformat_minor": 0
|
| 1031 |
+
}
|
aihub_Homework_LangChainAgents_20250601_gradio.ipynb
ADDED
|
@@ -0,0 +1,339 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"cells": [
|
| 3 |
+
{
|
| 4 |
+
"cell_type": "markdown",
|
| 5 |
+
"metadata": {
|
| 6 |
+
"id": "RPUwOvgUyZiz"
|
| 7 |
+
},
|
| 8 |
+
"source": [
|
| 9 |
+
"requiremenrs.txt\n",
|
| 10 |
+
"\n",
|
| 11 |
+
"langchain\n",
|
| 12 |
+
"langchain-openai\n",
|
| 13 |
+
"langchainhub # langchain python라이브러리로 프롬프트, 에이전트, 체인 관련 패키지 모음\n",
|
| 14 |
+
"langserve[all]\n",
|
| 15 |
+
"\n",
|
| 16 |
+
"faiss-cpu # Facebook에서 개발 및 배포한 밀집 벡터의 유사도 측정, 클러스터링에 효율적인 라이브러리\n",
|
| 17 |
+
"tavily-python # 언어 모델에 중립적인 디자인으로, 모든 LLM과 통합이 가능하도록 설계된 검색 API\n",
|
| 18 |
+
"beautifulsoup4 #파이썬에서 사용할 수 있는 웹데이터 크롤링 라이브러리\n",
|
| 19 |
+
"wikipedia\n",
|
| 20 |
+
"\n",
|
| 21 |
+
"fastapi # Python의 API를 빌드하기 위한 웹 프레임워크\n",
|
| 22 |
+
"uvicorn # ASGI(Asynchronous Server Gateway Interface) 서버\n",
|
| 23 |
+
"urllib3 # 파이썬에서 HTTP 요청을 보내고 받는 데 사용되는 강력하고 유연한 라이브러리\n",
|
| 24 |
+
"\n",
|
| 25 |
+
"python-dotenv\n",
|
| 26 |
+
"pypdf"
|
| 27 |
+
]
|
| 28 |
+
},
|
| 29 |
+
{
|
| 30 |
+
"cell_type": "code",
|
| 31 |
+
"execution_count": null,
|
| 32 |
+
"metadata": {
|
| 33 |
+
"id": "NMMJXo_JyjhQ"
|
| 34 |
+
},
|
| 35 |
+
"outputs": [],
|
| 36 |
+
"source": [
|
| 37 |
+
"!pip install langchain\n",
|
| 38 |
+
"!pip install langchain-openai\n",
|
| 39 |
+
"!pip install python-dotenv\n",
|
| 40 |
+
"!pip install langchain_community\n",
|
| 41 |
+
"!pip install pypdf\n",
|
| 42 |
+
"!pip install faiss-cpu\n",
|
| 43 |
+
"!pip install wikipedia\n",
|
| 44 |
+
"!pip install openai\n",
|
| 45 |
+
"!pip install gradio"
|
| 46 |
+
]
|
| 47 |
+
},
|
| 48 |
+
{
|
| 49 |
+
"cell_type": "markdown",
|
| 50 |
+
"metadata": {
|
| 51 |
+
"id": "jXEvb3WyJMcA"
|
| 52 |
+
},
|
| 53 |
+
"source": [
|
| 54 |
+
"Tavily Search 를 사용하기 위해서는 API KEY를 발급 받아 등록해야 함.\n",
|
| 55 |
+
"\n",
|
| 56 |
+
"[Tavily Search API 발급받기](https://app.tavily.com/sign-in)\n",
|
| 57 |
+
"\n",
|
| 58 |
+
"발급 받은 API KEY 를 다음과 같이 환경변수에 등록"
|
| 59 |
+
]
|
| 60 |
+
},
|
| 61 |
+
{
|
| 62 |
+
"cell_type": "code",
|
| 63 |
+
"execution_count": null,
|
| 64 |
+
"metadata": {
|
| 65 |
+
"id": "RIxxUDEZI6ZR"
|
| 66 |
+
},
|
| 67 |
+
"outputs": [],
|
| 68 |
+
"source": [
|
| 69 |
+
"import os\n",
|
| 70 |
+
"\n",
|
| 71 |
+
"# TAVILY API KEY를 기입합니다.\n",
|
| 72 |
+
"os.environ[\"TAVILY_API_KEY\"] = \"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\"\n",
|
| 73 |
+
"\n",
|
| 74 |
+
"# 디버깅을 위한 프로젝트명을 기입합니다.\n",
|
| 75 |
+
"os.environ[\"LANGCHAIN_PROJECT\"] = \"AGENT TUTORIAL\""
|
| 76 |
+
]
|
| 77 |
+
},
|
| 78 |
+
{
|
| 79 |
+
"cell_type": "code",
|
| 80 |
+
"execution_count": null,
|
| 81 |
+
"metadata": {
|
| 82 |
+
"id": "ys24Z3bfJHUf"
|
| 83 |
+
},
|
| 84 |
+
"outputs": [],
|
| 85 |
+
"source": [
|
| 86 |
+
"os.environ[\"OPENAI_API_KEY\"] = ''"
|
| 87 |
+
]
|
| 88 |
+
},
|
| 89 |
+
{
|
| 90 |
+
"cell_type": "code",
|
| 91 |
+
"execution_count": null,
|
| 92 |
+
"metadata": {
|
| 93 |
+
"id": "sEii2SHNJbAG"
|
| 94 |
+
},
|
| 95 |
+
"outputs": [],
|
| 96 |
+
"source": [
|
| 97 |
+
"# API KEY를 환경변수로 관리하기 위한 설정 파일\n",
|
| 98 |
+
"from dotenv import load_dotenv\n",
|
| 99 |
+
"\n",
|
| 100 |
+
"# API KEY 정보로드\n",
|
| 101 |
+
"load_dotenv()"
|
| 102 |
+
]
|
| 103 |
+
},
|
| 104 |
+
{
|
| 105 |
+
"cell_type": "code",
|
| 106 |
+
"execution_count": null,
|
| 107 |
+
"metadata": {
|
| 108 |
+
"id": "ezbT1NHQKP12"
|
| 109 |
+
},
|
| 110 |
+
"outputs": [],
|
| 111 |
+
"source": [
|
| 112 |
+
"#google drive load\n",
|
| 113 |
+
"from google.colab import drive\n",
|
| 114 |
+
"drive.mount('/content/drive')"
|
| 115 |
+
]
|
| 116 |
+
},
|
| 117 |
+
{
|
| 118 |
+
"cell_type": "code",
|
| 119 |
+
"execution_count": null,
|
| 120 |
+
"metadata": {
|
| 121 |
+
"id": "rKz2oCpl6lWK"
|
| 122 |
+
},
|
| 123 |
+
"outputs": [],
|
| 124 |
+
"source": [
|
| 125 |
+
"import gradio as gr\n",
|
| 126 |
+
"import os\n",
|
| 127 |
+
"import openai\n",
|
| 128 |
+
"import unicodedata\n",
|
| 129 |
+
"import json\n",
|
| 130 |
+
"from langchain.schema import Document\n",
|
| 131 |
+
"from langchain.text_splitter import RecursiveCharacterTextSplitter\n",
|
| 132 |
+
"from langchain.embeddings.openai import OpenAIEmbeddings\n",
|
| 133 |
+
"from langchain.vectorstores import FAISS\n",
|
| 134 |
+
"\n",
|
| 135 |
+
"# ─── 0) OpenAI API 키 설정 ─────────────────────────────────────────────────\n",
|
| 136 |
+
"openai.api_key = os.getenv(\"OPENAI_API_KEY\")\n",
|
| 137 |
+
"\n",
|
| 138 |
+
"# ─── 1) JSON 로드 + 메타데이터 추출 함수 ─────────────────────────────────────────\n",
|
| 139 |
+
"def load_documents_with_metadata(folder_path):\n",
|
| 140 |
+
" documents = []\n",
|
| 141 |
+
" for raw_filename in os.listdir(folder_path):\n",
|
| 142 |
+
" filename = unicodedata.normalize(\"NFC\", raw_filename)\n",
|
| 143 |
+
" file_path = os.path.join(folder_path, raw_filename)\n",
|
| 144 |
+
"\n",
|
| 145 |
+
" if not os.path.isfile(file_path):\n",
|
| 146 |
+
" continue\n",
|
| 147 |
+
" if not filename.endswith(\".json\"):\n",
|
| 148 |
+
" continue\n",
|
| 149 |
+
"\n",
|
| 150 |
+
" try:\n",
|
| 151 |
+
" parts = filename.replace(\".json\", \"\").split(\"_\")\n",
|
| 152 |
+
" emotion = parts[1] if len(parts) > 1 else \"unknown\"\n",
|
| 153 |
+
" relation = parts[2] if len(parts) > 2 else \"unknown\"\n",
|
| 154 |
+
"\n",
|
| 155 |
+
" with open(file_path, \"r\", encoding=\"utf-8\") as f:\n",
|
| 156 |
+
" data = json.load(f)\n",
|
| 157 |
+
" utterances = data.get(\"utterances\", [])\n",
|
| 158 |
+
" full_text = \"\\n\".join([utt.get(\"text\",\"\") for utt in utterances])\n",
|
| 159 |
+
" if full_text.strip() == \"\":\n",
|
| 160 |
+
" continue\n",
|
| 161 |
+
"\n",
|
| 162 |
+
" doc = Document(\n",
|
| 163 |
+
" page_content=full_text,\n",
|
| 164 |
+
" metadata={\"filename\": filename, \"emotion\": emotion, \"relation\": relation}\n",
|
| 165 |
+
" )\n",
|
| 166 |
+
" documents.append(doc)\n",
|
| 167 |
+
" except Exception as e:\n",
|
| 168 |
+
" print(f\"❌ 오류 발생 ({filename}): {e}\")\n",
|
| 169 |
+
"\n",
|
| 170 |
+
" return documents\n",
|
| 171 |
+
"\n",
|
| 172 |
+
"# ─── 2) 문서 분할 함수 ─────────────────────────────────────────────────────\n",
|
| 173 |
+
"def split_documents(documents):\n",
|
| 174 |
+
" splitter = RecursiveCharacterTextSplitter(chunk_size=300, chunk_overlap=50)\n",
|
| 175 |
+
" return splitter.split_documents(documents)\n",
|
| 176 |
+
"\n",
|
| 177 |
+
"# ─── 3) FAISS 인덱스 생성 혹은 로드 함수 ───────────────────────────────────\n",
|
| 178 |
+
"def create_or_load_faiss(index_dir, split_docs):\n",
|
| 179 |
+
" embeddings = OpenAIEmbeddings()\n",
|
| 180 |
+
" if os.path.isdir(index_dir) and os.path.exists(os.path.join(index_dir, \"index.faiss\")):\n",
|
| 181 |
+
" faiss_db = FAISS.load_local(index_dir, embeddings, allow_dangerous_deserialization=True)\n",
|
| 182 |
+
" print(\"✅ 기존 FAISS 인덱스를 로드했습니다.\")\n",
|
| 183 |
+
" else:\n",
|
| 184 |
+
" faiss_db = FAISS.from_documents(split_docs, embeddings)\n",
|
| 185 |
+
" os.makedirs(index_dir, exist_ok=True)\n",
|
| 186 |
+
" faiss_db.save_local(index_dir)\n",
|
| 187 |
+
" print(\"✅ 새로운 FAISS 인덱스를 생성하고 저장했습니다.\")\n",
|
| 188 |
+
" return faiss_db\n",
|
| 189 |
+
"\n",
|
| 190 |
+
"# ─── 4) 필터 + 유사도 검색 함수 ────────────────────────────────────────────────\n",
|
| 191 |
+
"def filtered_similarity_search(vectorstore, query, emotion=None, relation=None, k=3):\n",
|
| 192 |
+
" all_docs = vectorstore.docstore._dict.values()\n",
|
| 193 |
+
" filtered_docs = [\n",
|
| 194 |
+
" doc for doc in all_docs\n",
|
| 195 |
+
" if (emotion is None or doc.metadata.get(\"emotion\") == emotion)\n",
|
| 196 |
+
" and (relation is None or relation in doc.metadata.get(\"relation\"))\n",
|
| 197 |
+
" ]\n",
|
| 198 |
+
"\n",
|
| 199 |
+
" if not filtered_docs:\n",
|
| 200 |
+
" return []\n",
|
| 201 |
+
"\n",
|
| 202 |
+
" splitter = RecursiveCharacterTextSplitter(chunk_size=1000, chunk_overlap=100)\n",
|
| 203 |
+
" query_chunks = splitter.split_text(query)\n",
|
| 204 |
+
"\n",
|
| 205 |
+
" search_results = []\n",
|
| 206 |
+
" for chunk in query_chunks:\n",
|
| 207 |
+
" search_results.extend(vectorstore.similarity_search(chunk, k=k))\n",
|
| 208 |
+
" return search_results\n",
|
| 209 |
+
"\n",
|
| 210 |
+
"# ─── 5) 후보 중 최고 문서 선택 함수 ─────────────────────────────────────────────\n",
|
| 211 |
+
"def choose_best_doc_with_gpt(query, docs, model=\"gpt-4o-mini\"):\n",
|
| 212 |
+
" prompt_parts = [\n",
|
| 213 |
+
" \"당신은 대화 응답 후보를 평가하는 전문가입니다.\\n\",\n",
|
| 214 |
+
" f\"사용자 질문: \\\"{query}\\\"\\n\",\n",
|
| 215 |
+
" \"다음은 검색된 응답 후보들입니다.\\n\"\n",
|
| 216 |
+
" ]\n",
|
| 217 |
+
"\n",
|
| 218 |
+
" for idx, doc in enumerate(docs, start=1):\n",
|
| 219 |
+
" snippet = doc.page_content.strip().replace(\"\\n\", \" \")\n",
|
| 220 |
+
" if len(snippet) > 300:\n",
|
| 221 |
+
" snippet = snippet[:300] + \"...\"\n",
|
| 222 |
+
" prompt_parts.append(\n",
|
| 223 |
+
" f\"[{idx}]\\n\"\n",
|
| 224 |
+
" f\"Filename: {doc.metadata.get('filename')}\\n\"\n",
|
| 225 |
+
" f\"Emotion: {doc.metadata.get('emotion')}, Relation: {doc.metadata.get('relation')}\\n\"\n",
|
| 226 |
+
" f\"Content: \\\"{snippet}\\\"\\n\"\n",
|
| 227 |
+
" )\n",
|
| 228 |
+
"\n",
|
| 229 |
+
" prompt_parts.append(\n",
|
| 230 |
+
" \"\\n위 후보들 중에서, 사용자 질문에 가�� 적절한 응답을 하나 선택하고, 그 이유를 간단히 설명해주세요.\\n\"\n",
|
| 231 |
+
" \"반드시 다음 형식으로 응답해 주세요:\\n\"\n",
|
| 232 |
+
" \"선택: [번호]\\n\"\n",
|
| 233 |
+
" \"이유: [간단한 설명]\\n\"\n",
|
| 234 |
+
" )\n",
|
| 235 |
+
"\n",
|
| 236 |
+
" full_prompt = \"\\n\".join(prompt_parts)\n",
|
| 237 |
+
"\n",
|
| 238 |
+
" response = openai.chat.completions.create(\n",
|
| 239 |
+
" model=model,\n",
|
| 240 |
+
" messages=[\n",
|
| 241 |
+
" {\"role\": \"system\", \"content\": \"당신은 뛰어난 대화 평가자입니다.\"},\n",
|
| 242 |
+
" {\"role\": \"user\", \"content\": full_prompt}\n",
|
| 243 |
+
" ],\n",
|
| 244 |
+
" max_tokens=300,\n",
|
| 245 |
+
" temperature=0.0\n",
|
| 246 |
+
" )\n",
|
| 247 |
+
"\n",
|
| 248 |
+
" gpt_reply = response.choices[0].message.content.strip()\n",
|
| 249 |
+
" selected_idx = None\n",
|
| 250 |
+
" for line in gpt_reply.splitlines():\n",
|
| 251 |
+
" if line.strip().startswith(\"선택\"):\n",
|
| 252 |
+
" import re\n",
|
| 253 |
+
" m = re.search(r\"\\[(\\d+)\\]\", line)\n",
|
| 254 |
+
" if m:\n",
|
| 255 |
+
" selected_idx = int(m.group(1))\n",
|
| 256 |
+
" break\n",
|
| 257 |
+
"\n",
|
| 258 |
+
" if selected_idx is None or selected_idx < 1 or selected_idx > len(docs):\n",
|
| 259 |
+
" selected_idx = 1\n",
|
| 260 |
+
"\n",
|
| 261 |
+
" best_doc = docs[selected_idx - 1]\n",
|
| 262 |
+
" return best_doc, gpt_reply\n",
|
| 263 |
+
"\n",
|
| 264 |
+
"# ─── 6) 최종 답변 간결하게 생성 함수 ─────────────────────────────────────────────\n",
|
| 265 |
+
"def generate_final_answer(query, best_doc, model=\"gpt-4o-mini\"):\n",
|
| 266 |
+
" prompt = (\n",
|
| 267 |
+
" \"다음은 사용자의 질문과, 선택된 최적 응답 후보입니다.\\n\\n\"\n",
|
| 268 |
+
" f\"사용자 질문: \\\"{query}\\\"\\n\"\n",
|
| 269 |
+
" \"선택된 후보 응답 내용(원문):\\n\"\n",
|
| 270 |
+
" f\"\\\"\\\"\\\"\\n{best_doc.page_content}\\n\\\"\\\"\\\"\\n\\n\"\n",
|
| 271 |
+
" \"위 원문에서, 불필요한 반복/인사말/개인정보 등은 모두 제거하고, \"\n",
|
| 272 |
+
" \"사용자가 이해하기 쉽도록 핵심만 남겨 간결하게 재작성해주세요.\\n\"\n",
|
| 273 |
+
" \"문체는 친절하고 공감 가득한 톤을 유지해 주시고, \"\n",
|
| 274 |
+
" \"최종 답변만 출력해 주세요.\"\n",
|
| 275 |
+
" )\n",
|
| 276 |
+
"\n",
|
| 277 |
+
" response = openai.chat.completions.create(\n",
|
| 278 |
+
" model=model,\n",
|
| 279 |
+
" messages=[\n",
|
| 280 |
+
" {\"role\": \"system\", \"content\": \"당신은 친절하고 공감능력이 뛰어난 상담사입니다.\"},\n",
|
| 281 |
+
" {\"role\": \"user\", \"content\": prompt}\n",
|
| 282 |
+
" ],\n",
|
| 283 |
+
" max_tokens=300,\n",
|
| 284 |
+
" temperature=0.7\n",
|
| 285 |
+
" )\n",
|
| 286 |
+
"\n",
|
| 287 |
+
" final_answer = response.choices[0].message.content.strip()\n",
|
| 288 |
+
" return final_answer\n",
|
| 289 |
+
"\n",
|
| 290 |
+
"# ─── 7) Gradio 응용: 채팅 인터페이스 구축 ───────────────────────────────────────\n",
|
| 291 |
+
"index_dir = \"/content/drive/MyDrive/2025_Bigdata_nlp_class/faiss_index\"\n",
|
| 292 |
+
"folder_path = \"/content/drive/MyDrive/2025_Bigdata_nlp_class/aihub_dataset/Training/02_label_data\"\n",
|
| 293 |
+
"\n",
|
| 294 |
+
"# 문서 로드 및 FAISS 초기화\n",
|
| 295 |
+
"documents = load_documents_with_metadata(folder_path)\n",
|
| 296 |
+
"split_docs = split_documents(documents)\n",
|
| 297 |
+
"faiss_db = create_or_load_faiss(index_dir, split_docs)\n",
|
| 298 |
+
"\n",
|
| 299 |
+
"def chat_response(query, emotion, relation):\n",
|
| 300 |
+
" candidates = filtered_similarity_search(faiss_db, query, emotion, relation)\n",
|
| 301 |
+
" if not candidates:\n",
|
| 302 |
+
" return \"조건에 맞는 문서가 없습니다.\"\n",
|
| 303 |
+
"\n",
|
| 304 |
+
" best_doc, _ = choose_best_doc_with_gpt(query, candidates, model=\"gpt-4o-mini\")\n",
|
| 305 |
+
" final_answer = generate_final_answer(query, best_doc, model=\"gpt-4o-mini\")\n",
|
| 306 |
+
" return final_answer\n",
|
| 307 |
+
"\n",
|
| 308 |
+
"with gr.Blocks() as demo:\n",
|
| 309 |
+
" gr.Markdown(\"## 감정/관계 기반 Empathy QA 시스템\")\n",
|
| 310 |
+
" with gr.Row():\n",
|
| 311 |
+
" txt_query = gr.Textbox(label=\"질문\", placeholder=\"질문을 입력하세요...\", lines=2)\n",
|
| 312 |
+
" with gr.Row():\n",
|
| 313 |
+
" txt_emotion = gr.Textbox(label=\"Emotion (예: 기쁨, 당황, 분노)\", placeholder=\"ex) 기쁨\")\n",
|
| 314 |
+
" txt_relation = gr.Textbox(label=\"Relation (예: 부모자녀, 부부, 연인)\", placeholder=\"ex) 부모자녀\")\n",
|
| 315 |
+
" btn_submit = gr.Button(\"전송\")\n",
|
| 316 |
+
" output = gr.Textbox(label=\"답변\", lines=5)\n",
|
| 317 |
+
"\n",
|
| 318 |
+
" btn_submit.click(chat_response, inputs=[txt_query, txt_emotion, txt_relation], outputs=output)\n",
|
| 319 |
+
"\n",
|
| 320 |
+
"demo.launch()\n"
|
| 321 |
+
]
|
| 322 |
+
}
|
| 323 |
+
],
|
| 324 |
+
"metadata": {
|
| 325 |
+
"colab": {
|
| 326 |
+
"private_outputs": true,
|
| 327 |
+
"provenance": []
|
| 328 |
+
},
|
| 329 |
+
"kernelspec": {
|
| 330 |
+
"display_name": "Python 3",
|
| 331 |
+
"name": "python3"
|
| 332 |
+
},
|
| 333 |
+
"language_info": {
|
| 334 |
+
"name": "python"
|
| 335 |
+
}
|
| 336 |
+
},
|
| 337 |
+
"nbformat": 4,
|
| 338 |
+
"nbformat_minor": 0
|
| 339 |
+
}
|
docker-compose.yml
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
services:
|
| 2 |
+
simsimi-ai-agent:
|
| 3 |
+
build:
|
| 4 |
+
context: .
|
| 5 |
+
dockerfile: Dockerfile
|
| 6 |
+
container_name: simsimi_ai_agent
|
| 7 |
+
ports:
|
| 8 |
+
- "8000:8000"
|
| 9 |
+
volumes:
|
| 10 |
+
# 소스코드 실시간 반영 (개발용)
|
| 11 |
+
- ./src:/app/src
|
| 12 |
+
- ./scripts:/app/scripts
|
| 13 |
+
- ./main.py:/app/main.py
|
| 14 |
+
# 데이터 영구 저장
|
| 15 |
+
- ./data:/app/data
|
| 16 |
+
- ./logs:/app/logs
|
| 17 |
+
# [추가] 캐시 데이터 영구 저장
|
| 18 |
+
# 이렇게 하면 컨테이너를 껐다 켜도 매번 모델을 새로 다운로드하지 않습니다.
|
| 19 |
+
- ./cache:/app/cache
|
| 20 |
+
# 환경변수 (로컬에서만)
|
| 21 |
+
- ./.env:/app/.env:ro
|
| 22 |
+
environment:
|
| 23 |
+
- PYTHONPATH=/app
|
| 24 |
+
- PYTHONDONTWRITEBYTECODE=1
|
| 25 |
+
- PYTHONUNBUFFERED=1
|
| 26 |
+
# [추가] Hugging Face 캐시 디렉토리 환경 변수
|
| 27 |
+
- HF_HOME=/app/cache
|
| 28 |
+
env_file:
|
| 29 |
+
- .env
|
| 30 |
+
restart: unless-stopped
|
| 31 |
+
stdin_open: true
|
| 32 |
+
tty: true
|
| 33 |
+
networks:
|
| 34 |
+
- simsimi_network
|
| 35 |
+
|
| 36 |
+
networks:
|
| 37 |
+
simsimi_network:
|
| 38 |
+
driver: bridge
|
load_data.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import uuid
|
| 3 |
+
from sentence_transformers import SentenceTransformer
|
| 4 |
+
import chromadb
|
| 5 |
+
from chromadb.utils import embedding_functions
|
| 6 |
+
import os
|
| 7 |
+
|
| 8 |
+
# --- 설정 ---
|
| 9 |
+
# 이 파일과 같은 위치에 AI Hub 원본 데이터 파일이 있다고 가정합니다.
|
| 10 |
+
SOURCE_DATA_FILE = 'AI_Hub_감성대화.json'
|
| 11 |
+
DB_PATH = "./data/chromadb"
|
| 12 |
+
COLLECTION_NAME = "teen_empathy_chat"
|
| 13 |
+
MODEL_NAME = 'jhgan/ko-sbert-multitask'
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def setup_database():
|
| 17 |
+
"""VectorDB를 설정하고 데이터를 구축하는 메인 함수"""
|
| 18 |
+
|
| 19 |
+
# 0. 필수 파일 확인
|
| 20 |
+
if not os.path.exists(SOURCE_DATA_FILE):
|
| 21 |
+
print(f"오류: 원본 데이터 파일 '{SOURCE_DATA_FILE}'을 찾을 수 없습니다.")
|
| 22 |
+
print("AI Hub 데이터를 다운로드하여 이 스크립트와 같은 폴더에 저장해주세요.")
|
| 23 |
+
return
|
| 24 |
+
|
| 25 |
+
print("1. 데이터베이스 및 컬렉션 설정 시작...")
|
| 26 |
+
client = chromadb.PersistentClient(path=DB_PATH)
|
| 27 |
+
|
| 28 |
+
# HuggingFace 임베딩 함수 설정
|
| 29 |
+
embedding_func = embedding_functions.SentenceTransformerEmbeddingFunction(model_name=MODEL_NAME)
|
| 30 |
+
|
| 31 |
+
# 컬렉션 생성 또는 가져오기
|
| 32 |
+
collection = client.get_or_create_collection(
|
| 33 |
+
name=COLLECTION_NAME,
|
| 34 |
+
embedding_function=embedding_func,
|
| 35 |
+
metadata={"hnsw:space": "cosine"} # 유사도 측정 기준: 코사인 유사도
|
| 36 |
+
)
|
| 37 |
+
print(f"'{COLLECTION_NAME}' 컬렉션 준비 완료.")
|
| 38 |
+
|
| 39 |
+
# 2. 원본 JSON 데이터 로드
|
| 40 |
+
print(f"2. '{SOURCE_DATA_FILE}' 파일에서 데이터 로드 중...")
|
| 41 |
+
with open(SOURCE_DATA_FILE, 'r', encoding='utf-8') as f:
|
| 42 |
+
data = json.load(f)
|
| 43 |
+
print(f"총 {len(data)}개의 대화 데이터 로드 완료.")
|
| 44 |
+
|
| 45 |
+
# 3. 데이터 배치 처리 및 VectorDB에 추가
|
| 46 |
+
print("3. 데이터 임베딩 및 데이터베이스 저장 시작... (시간이 걸릴 수 있습니다)")
|
| 47 |
+
batch_size = 100
|
| 48 |
+
total_batches = (len(data) + batch_size - 1) // batch_size
|
| 49 |
+
|
| 50 |
+
for i in range(0, len(data), batch_size):
|
| 51 |
+
batch_data = data[i:i + batch_size]
|
| 52 |
+
|
| 53 |
+
# 문서, 메타데이터, ID 리스트 생성
|
| 54 |
+
documents = [item['user_utterance'] for item in batch_data]
|
| 55 |
+
metadatas = [
|
| 56 |
+
{
|
| 57 |
+
"user_utterance": item['user_utterance'],
|
| 58 |
+
"system_response": item['system_response'],
|
| 59 |
+
"emotion": item['emotion'],
|
| 60 |
+
"relationship": item.get('relationship', '기타') # relationship 필드가 없을 경우 대비
|
| 61 |
+
} for item in batch_data
|
| 62 |
+
]
|
| 63 |
+
ids = [str(uuid.uuid4()) for _ in batch_data]
|
| 64 |
+
|
| 65 |
+
# 컬렉션에 데이터 추가
|
| 66 |
+
collection.add(
|
| 67 |
+
documents=documents,
|
| 68 |
+
metadatas=metadatas,
|
| 69 |
+
ids=ids
|
| 70 |
+
)
|
| 71 |
+
|
| 72 |
+
print(f" - 배치 {i // batch_size + 1}/{total_batches} 처리 완료...")
|
| 73 |
+
|
| 74 |
+
print("🎉 데이터베이스 구축이 성공적으로 완료되었습니다!")
|
| 75 |
+
print(f"총 {collection.count()}개의 문서가 '{COLLECTION_NAME}' 컬렉션에 저장되었습니다.")
|
| 76 |
+
print(f"데이터베이스는 '{DB_PATH}' 경로에 저장되었습니다.")
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
if __name__ == "__main__":
|
| 80 |
+
setup_database()
|
main.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
# ==========================================================
|
| 3 |
+
# 앱 시작 시 Hugging Face Dataset에서 데이터 다운로드
|
| 4 |
+
# ==========================================================
|
| 5 |
+
from huggingface_hub import snapshot_download
|
| 6 |
+
import os
|
| 7 |
+
|
| 8 |
+
# 1. 본인의 Dataset 저장소 주소를 입력하세요.
|
| 9 |
+
HF_DATASET_REPO_ID = "youdie006/simsimi-ai-agent-data"
|
| 10 |
+
DATA_DIR = "./data"
|
| 11 |
+
|
| 12 |
+
# 2. [추가] 권한 문제가 없는 캐시 디렉토리 경로를 명시적으로 지정합니다.
|
| 13 |
+
CACHE_DIR = "/app/cache"
|
| 14 |
+
|
| 15 |
+
# 3. 데이터 파일이 이미 있는지 확인하고, 없을 때만 다운로드 실행
|
| 16 |
+
if not os.path.exists(os.path.join(DATA_DIR, "chromadb/chroma.sqlite3")):
|
| 17 |
+
print(f"'{HF_DATASET_REPO_ID}'에서 데이터 다운로드 시작...")
|
| 18 |
+
snapshot_download(
|
| 19 |
+
repo_id=HF_DATASET_REPO_ID,
|
| 20 |
+
repo_type="dataset",
|
| 21 |
+
local_dir=DATA_DIR,
|
| 22 |
+
local_dir_use_symlinks=False,
|
| 23 |
+
cache_dir=CACHE_DIR # <--- 이 부분이 핵심입니다.
|
| 24 |
+
)
|
| 25 |
+
print("데이터 다운로드 완료.")
|
| 26 |
+
# ==========================================================
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
"""
|
| 30 |
+
청소년 공감형 AI 챗봇 메인 서버
|
| 31 |
+
"""
|
| 32 |
+
from fastapi import FastAPI, HTTPException
|
| 33 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 34 |
+
from fastapi.responses import JSONResponse, HTMLResponse
|
| 35 |
+
from fastapi.staticfiles import StaticFiles
|
| 36 |
+
import sys
|
| 37 |
+
from datetime import datetime
|
| 38 |
+
from dotenv import load_dotenv
|
| 39 |
+
|
| 40 |
+
# 환경 변수 로드
|
| 41 |
+
load_dotenv()
|
| 42 |
+
|
| 43 |
+
# FastAPI 앱 생성
|
| 44 |
+
app = FastAPI(
|
| 45 |
+
title="💙 마음이 - 청소년 상담 챗봇",
|
| 46 |
+
description="13-19세 청소년을 위한 AI 공감 상담사",
|
| 47 |
+
version=os.getenv("VERSION", "2.0.0"),
|
| 48 |
+
docs_url="/docs",
|
| 49 |
+
redoc_url="/redoc"
|
| 50 |
+
)
|
| 51 |
+
|
| 52 |
+
# CORS 설정
|
| 53 |
+
app.add_middleware(
|
| 54 |
+
CORSMiddleware,
|
| 55 |
+
allow_origins=["*"],
|
| 56 |
+
allow_credentials=True,
|
| 57 |
+
allow_methods=["*"],
|
| 58 |
+
allow_headers=["*"],
|
| 59 |
+
)
|
| 60 |
+
|
| 61 |
+
# 정적 파일 서빙
|
| 62 |
+
try:
|
| 63 |
+
app.mount("/static", StaticFiles(directory="static"), name="static")
|
| 64 |
+
print("✅ 정적 파일 서빙 설정 완료")
|
| 65 |
+
except Exception as e:
|
| 66 |
+
print(f"⚠️ 정적 파일 디렉토리 없음: {e}")
|
| 67 |
+
|
| 68 |
+
# 라우터 등록
|
| 69 |
+
try:
|
| 70 |
+
from src.api import vector, openai, chat
|
| 71 |
+
|
| 72 |
+
app.include_router(
|
| 73 |
+
vector.router,
|
| 74 |
+
prefix="/api/v1/vector",
|
| 75 |
+
tags=["🗄️ Vector Store"]
|
| 76 |
+
)
|
| 77 |
+
app.include_router(
|
| 78 |
+
openai.router,
|
| 79 |
+
prefix="/api/v1/openai",
|
| 80 |
+
tags=["🤖 OpenAI GPT-4"]
|
| 81 |
+
)
|
| 82 |
+
app.include_router(
|
| 83 |
+
chat.router,
|
| 84 |
+
prefix="/api/v1/chat",
|
| 85 |
+
tags=["💙 Teen Chat"]
|
| 86 |
+
)
|
| 87 |
+
except ImportError as e:
|
| 88 |
+
print(f"⚠️ API 라우터 import 실패: {e}")
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
@app.get("/", response_class=HTMLResponse)
|
| 92 |
+
async def web_chat_interface():
|
| 93 |
+
"""웹 채팅 인터페이스"""
|
| 94 |
+
html_file_path = "static/index.html"
|
| 95 |
+
if os.path.exists(html_file_path):
|
| 96 |
+
with open(html_file_path, "r", encoding="utf-8") as f:
|
| 97 |
+
return HTMLResponse(content=f.read())
|
| 98 |
+
return HTMLResponse(content="<h1>Welcome</h1><p>Static index.html not found.</p>")
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
@app.get("/api/v1/health")
|
| 102 |
+
async def health_check():
|
| 103 |
+
"""시스템 헬스 체크"""
|
| 104 |
+
return {"status": "healthy", "service": "teen-empathy-chatbot"}
|
requirements.txt
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ===========================================
|
| 2 |
+
# 🌐 웹 프레임워크
|
| 3 |
+
# ===========================================
|
| 4 |
+
fastapi==0.104.1
|
| 5 |
+
uvicorn[standard]==0.24.0
|
| 6 |
+
|
| 7 |
+
# ===========================================
|
| 8 |
+
# 🤖 AI/ML 라이브러리
|
| 9 |
+
# ===========================================
|
| 10 |
+
openai==1.3.8
|
| 11 |
+
sentence-transformers==2.2.2
|
| 12 |
+
torch==2.0.1
|
| 13 |
+
transformers==4.30.0
|
| 14 |
+
|
| 15 |
+
# 🔧 HuggingFace Hub (호환성 버전 고정)
|
| 16 |
+
huggingface_hub==0.16.4 # cached_download 지원 마지막 안정 버전
|
| 17 |
+
|
| 18 |
+
# ===========================================
|
| 19 |
+
# 🗄️ Vector Database
|
| 20 |
+
# ===========================================
|
| 21 |
+
chromadb==0.4.18
|
| 22 |
+
|
| 23 |
+
# ===========================================
|
| 24 |
+
# 🛠️ 유틸리티
|
| 25 |
+
# ===========================================
|
| 26 |
+
python-dotenv==1.0.0
|
| 27 |
+
pydantic==2.5.0
|
| 28 |
+
httpx==0.25.2
|
| 29 |
+
loguru==0.7.2
|
| 30 |
+
numpy==1.24.3
|
| 31 |
+
pandas==2.0.3
|
| 32 |
+
|
| 33 |
+
# ===========================================
|
| 34 |
+
# 🇰🇷 한국어 처리
|
| 35 |
+
# ===========================================
|
| 36 |
+
konlpy==0.6.0
|
| 37 |
+
|
| 38 |
+
# ===========================================
|
| 39 |
+
# 📊 AI Hub 데이터 처리 (추가)
|
| 40 |
+
# ===========================================
|
| 41 |
+
# JSON 스트리밍 처리
|
| 42 |
+
ijson==3.2.3
|
| 43 |
+
|
| 44 |
+
# 데이터 분석 및 통계
|
| 45 |
+
scipy==1.11.4
|
| 46 |
+
scikit-learn==1.3.2
|
| 47 |
+
|
| 48 |
+
# 텍스트 전처리 및 정규식
|
| 49 |
+
regex==2023.10.3
|
| 50 |
+
|
| 51 |
+
# 날짜/시간 처리
|
| 52 |
+
python-dateutil==2.8.2
|
| 53 |
+
|
| 54 |
+
# ===========================================
|
| 55 |
+
# 🔧 개발 및 테스팅 (Docker에서 제외 가능)
|
| 56 |
+
# ===========================================
|
| 57 |
+
# 주석 처리하여 빌드 시간 단축
|
| 58 |
+
# pytest==7.4.3
|
| 59 |
+
# pytest-asyncio==0.21.1
|
| 60 |
+
|
| 61 |
+
# 성능 모니터링 (프로덕션에서만 필요시)
|
| 62 |
+
# psutil==5.9.6
|
| 63 |
+
|
| 64 |
+
# ===========================================
|
| 65 |
+
# 🐳 Docker 최적화
|
| 66 |
+
# ===========================================
|
| 67 |
+
# Gunicorn (프로덕션 배포용)
|
| 68 |
+
gunicorn==22.0.0
|
| 69 |
+
|
| 70 |
+
# ===========================================
|
| 71 |
+
# 📝 추가 유틸리티
|
| 72 |
+
# ===========================================
|
| 73 |
+
# 환경 변수 관리
|
| 74 |
+
python-decouple==3.8
|
| 75 |
+
|
| 76 |
+
# 참고: uuid, time은 Python 표준 라이브러리 사용
|
src/__init__.py
ADDED
|
File without changes
|
src/api/__init__.py
ADDED
|
File without changes
|
src/api/chat.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Header
|
| 2 |
+
import traceback
|
| 3 |
+
from loguru import logger
|
| 4 |
+
from ..services.openai_client import get_openai_client
|
| 5 |
+
from ..services.aihub_processor import get_teen_empathy_processor
|
| 6 |
+
from ..models.function_models import TeenChatRequest, ReActStep, EmotionType, RelationshipType
|
| 7 |
+
from ..services.conversation_service import get_conversation_service
|
| 8 |
+
|
| 9 |
+
router = APIRouter()
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
async def run_pipeline(session_id: str, message: str) -> dict:
|
| 13 |
+
"""모든 처리 과정을 투명하게 추적하는 최종 파이프라인 (RAG-Fusion 적용)"""
|
| 14 |
+
|
| 15 |
+
openai_client = await get_openai_client()
|
| 16 |
+
conversation_service = await get_conversation_service()
|
| 17 |
+
processor = await get_teen_empathy_processor()
|
| 18 |
+
|
| 19 |
+
debug_info = {}
|
| 20 |
+
react_steps = []
|
| 21 |
+
|
| 22 |
+
# Step 1: Context Loading
|
| 23 |
+
session_id = await conversation_service.get_or_create_session(session_id)
|
| 24 |
+
conversation_history = await conversation_service.get_conversation_history(session_id)
|
| 25 |
+
debug_info["step1_context_loading"] = {"session_id": session_id, "loaded_history": conversation_history}
|
| 26 |
+
|
| 27 |
+
# Step 2: Input Analysis
|
| 28 |
+
react_steps.append(ReActStep(step_type="thought", content="사용자의 입력 의도를 파악하기 위해 감정과 관계 맥락을 분석해야겠다."))
|
| 29 |
+
analysis_result = await openai_client.analyze_emotion_and_context(message)
|
| 30 |
+
emotion = analysis_result.get("primary_emotion", EmotionType.ANGER.value)
|
| 31 |
+
relationship = analysis_result.get("relationship_context", RelationshipType.FAMILY.value)
|
| 32 |
+
react_steps.append(ReActStep(step_type="observation", content=f"분석 결과: 감정='{emotion}', 관계='{relationship}'"))
|
| 33 |
+
debug_info["step2_input_analysis"] = {"input": message, "output": analysis_result}
|
| 34 |
+
|
| 35 |
+
# Step 3: Conversational Query Rewriting
|
| 36 |
+
react_steps.append(ReActStep(step_type="thought", content="RAG 검색 정확도를 높이기 위해, 이전 대화 내용까지 포함하여 검색어를 재작성해야겠다."))
|
| 37 |
+
search_query = await openai_client.rewrite_query_with_history(message, conversation_history)
|
| 38 |
+
react_steps.append(ReActStep(step_type="observation", content=f"재작성된 검색어: '{search_query}'"))
|
| 39 |
+
debug_info["step3_query_rewriting"] = {"original_message": message, "rewritten_query": search_query}
|
| 40 |
+
|
| 41 |
+
# Step 4: RAG Retrieval
|
| 42 |
+
react_steps.append(ReActStep(step_type="thought", content="재작성된 검색어로 여러 개의 후보 문서를 찾아봐야겠다."))
|
| 43 |
+
expert_responses = await processor.search_similar_contexts(query=search_query, emotion=emotion,
|
| 44 |
+
relationship=relationship, top_k=5)
|
| 45 |
+
react_steps.append(ReActStep(step_type="observation", content=f"유사 사례 후보 {len(expert_responses)}건 발견."))
|
| 46 |
+
debug_info["step4_rag_retrieval"] = {"retrieved_candidates": expert_responses}
|
| 47 |
+
|
| 48 |
+
# Step 5: Sequential Relevance Check & Strategy Decision
|
| 49 |
+
strategy = "Direct-Generation"
|
| 50 |
+
final_expert_advice = None
|
| 51 |
+
verification_logs = []
|
| 52 |
+
|
| 53 |
+
react_steps.append(ReActStep(step_type="thought", content="검색된 후보들이 현재 대화와 정말 관련이 있는지 하나씩 순서대로 검증해야겠다."))
|
| 54 |
+
for i, doc in enumerate(expert_responses):
|
| 55 |
+
doc_content = doc.get("system_response", "")
|
| 56 |
+
is_relevant = await openai_client.verify_rag_relevance(message, doc_content)
|
| 57 |
+
verification_logs.append({"candidate": i + 1, "is_relevant": is_relevant, "document": doc})
|
| 58 |
+
if is_relevant:
|
| 59 |
+
strategy = "RAG-Adaptation"
|
| 60 |
+
final_expert_advice = doc_content
|
| 61 |
+
react_steps.append(ReActStep(step_type="observation", content=f"후보 {i + 1}번이 관련 있음! RAG 전략을 사용하기로 결정했다."))
|
| 62 |
+
break
|
| 63 |
+
|
| 64 |
+
if not final_expert_advice:
|
| 65 |
+
react_steps.append(
|
| 66 |
+
ReActStep(step_type="observation", content="관련 있는 문서를 찾지 못했으므로, 검색된 문서들을 '영감'으로 삼아 직접 생성 전략을 사용한다."))
|
| 67 |
+
|
| 68 |
+
debug_info["step5_strategy_decision"] = {"chosen_strategy": strategy, "verification_logs": verification_logs}
|
| 69 |
+
|
| 70 |
+
# Step 6: Final Response Generation
|
| 71 |
+
react_steps.append(ReActStep(step_type="thought", content=f"'{strategy}' 전략을 사용해, 최종 답변을 생성한다."))
|
| 72 |
+
final_response = ""
|
| 73 |
+
if strategy == "RAG-Adaptation":
|
| 74 |
+
raw_advice, pre_adapted, final_adapted, final_prompt = await openai_client.adapt_expert_response(
|
| 75 |
+
final_expert_advice, message, conversation_history)
|
| 76 |
+
final_response = final_adapted
|
| 77 |
+
debug_info["step6_generation"] = {"strategy": strategy, "A_source_expert_advice": raw_advice,
|
| 78 |
+
"B_rule_based_adaptation": pre_adapted, "C_final_gpt4_prompt": final_prompt,
|
| 79 |
+
"D_final_response": final_response}
|
| 80 |
+
else:
|
| 81 |
+
# [최종 업그레이드] RAG-Fusion 적용: 실패한 RAG 결과를 '영감'으로 제공
|
| 82 |
+
inspirational_docs = [doc.get("system_response", "") for doc in expert_responses]
|
| 83 |
+
final_response, final_prompt = await openai_client.create_direct_response(
|
| 84 |
+
user_message=message,
|
| 85 |
+
conversation_history=conversation_history,
|
| 86 |
+
inspirational_docs=inspirational_docs # <-- 추가된 부분
|
| 87 |
+
)
|
| 88 |
+
debug_info["step6_generation"] = {"strategy": strategy, "A_final_gpt4_prompt": final_prompt,
|
| 89 |
+
"B_final_response": final_response}
|
| 90 |
+
|
| 91 |
+
react_steps.append(ReActStep(step_type="observation", content="최종 응답 생성을 완료했다."))
|
| 92 |
+
|
| 93 |
+
# Step 7: Save Conversation
|
| 94 |
+
await conversation_service.save_conversation_turn(session_id, message, final_response)
|
| 95 |
+
debug_info["step7_save_conversation"] = {"user": message, "assistant": final_response}
|
| 96 |
+
|
| 97 |
+
return {"response": final_response, "debug_info": debug_info, "react_steps": [r.dict() for r in react_steps]}
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
@router.post("/teen-chat-debug")
|
| 101 |
+
async def teen_chat_debug(request: TeenChatRequest, session_id: str = Header(None)):
|
| 102 |
+
try:
|
| 103 |
+
return await run_pipeline(session_id, request.message)
|
| 104 |
+
except Exception as e:
|
| 105 |
+
tb_str = traceback.format_exc();
|
| 106 |
+
logger.error(f"디버깅 파이프라인 실패: {e}\n{tb_str}")
|
| 107 |
+
return {"error": "Pipeline Error", "error_message": str(e), "debug_info": {"traceback": tb_str}}
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
@router.post("/teen-chat")
|
| 111 |
+
async def teen_chat(request: TeenChatRequest, session_id: str = Header(None)):
|
| 112 |
+
result = await run_pipeline(session_id, request.message)
|
| 113 |
+
return {"response": result["response"]}
|
src/api/openai.py
ADDED
|
@@ -0,0 +1,337 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
OpenAI API 라우터
|
| 3 |
+
GPT-4 관련 엔드포인트들 (채팅, 감정분석 등)
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from fastapi import APIRouter, HTTPException, Depends, status
|
| 7 |
+
from typing import List, Dict, Any
|
| 8 |
+
from loguru import logger
|
| 9 |
+
|
| 10 |
+
from ..services.openai_client import get_openai_client
|
| 11 |
+
from ..models.function_models import (
|
| 12 |
+
OpenAICompletionRequest, OpenAICompletionResponse,
|
| 13 |
+
EmotionAnalysisRequest, EmotionAnalysisResponse,
|
| 14 |
+
ChatMessage, SystemHealthCheck
|
| 15 |
+
)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
router = APIRouter()
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
@router.post("/completion", response_model=OpenAICompletionResponse)
|
| 22 |
+
async def create_completion(
|
| 23 |
+
request: OpenAICompletionRequest,
|
| 24 |
+
openai_client = Depends(get_openai_client)
|
| 25 |
+
):
|
| 26 |
+
"""
|
| 27 |
+
🤖 GPT-4 채팅 완성 생성
|
| 28 |
+
|
| 29 |
+
- 일반적인 GPT-4 채팅 완성
|
| 30 |
+
- 사용자 정의 모델, 온도, 토큰 수 설정 가능
|
| 31 |
+
- 스트리밍 지원 (선택적)
|
| 32 |
+
"""
|
| 33 |
+
try:
|
| 34 |
+
logger.info(f"GPT-4 완성 요청 - 모델: {request.model}, 메시지 수: {len(request.messages)}")
|
| 35 |
+
|
| 36 |
+
# ChatMessage를 dict로 변환
|
| 37 |
+
messages = [
|
| 38 |
+
{"role": msg.role.value, "content": msg.content}
|
| 39 |
+
for msg in request.messages
|
| 40 |
+
]
|
| 41 |
+
|
| 42 |
+
response = await openai_client.create_completion(
|
| 43 |
+
messages=messages,
|
| 44 |
+
model=request.model,
|
| 45 |
+
temperature=request.temperature,
|
| 46 |
+
max_tokens=request.max_tokens,
|
| 47 |
+
top_p=request.top_p
|
| 48 |
+
)
|
| 49 |
+
|
| 50 |
+
return response
|
| 51 |
+
|
| 52 |
+
except Exception as e:
|
| 53 |
+
logger.error(f"GPT-4 완성 생성 실패: {e}")
|
| 54 |
+
raise HTTPException(
|
| 55 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 56 |
+
detail=f"GPT-4 완성 생성 중 오류가 발생했습니다: {str(e)}"
|
| 57 |
+
)
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
@router.post("/teen-empathy", response_model=str)
|
| 61 |
+
async def create_teen_empathy_response(
|
| 62 |
+
user_message: str,
|
| 63 |
+
conversation_history: List[ChatMessage] = None,
|
| 64 |
+
context_info: str = None,
|
| 65 |
+
openai_client = Depends(get_openai_client)
|
| 66 |
+
):
|
| 67 |
+
"""
|
| 68 |
+
💙 청소년 공감형 응답 생성
|
| 69 |
+
|
| 70 |
+
- 청소년 전용 공감 시스템 프롬프트 적용
|
| 71 |
+
- 대화 히스토리 및 맥락 정보 활용
|
| 72 |
+
- 따뜻하고 지지적인 응답 생성
|
| 73 |
+
"""
|
| 74 |
+
try:
|
| 75 |
+
logger.info(f"청소년 공감 응답 요청: '{user_message[:50]}...'")
|
| 76 |
+
|
| 77 |
+
response = await openai_client.create_teen_empathy_response(
|
| 78 |
+
user_message=user_message,
|
| 79 |
+
conversation_history=conversation_history,
|
| 80 |
+
context_info=context_info
|
| 81 |
+
)
|
| 82 |
+
|
| 83 |
+
return response
|
| 84 |
+
|
| 85 |
+
except Exception as e:
|
| 86 |
+
logger.error(f"청소년 공감 응답 생성 실패: {e}")
|
| 87 |
+
raise HTTPException(
|
| 88 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 89 |
+
detail=f"청소년 공감 응답 생성 중 오류가 발생했습니다: {str(e)}"
|
| 90 |
+
)
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
@router.post("/analyze-emotion", response_model=EmotionAnalysisResponse)
|
| 94 |
+
async def analyze_emotion(
|
| 95 |
+
request: EmotionAnalysisRequest,
|
| 96 |
+
openai_client = Depends(get_openai_client)
|
| 97 |
+
):
|
| 98 |
+
"""
|
| 99 |
+
🎭 감정 및 맥락 분석
|
| 100 |
+
|
| 101 |
+
- 텍스트에서 주요 감정 추출
|
| 102 |
+
- 관계 맥락 파악 (부모님, 친구, 형제자매 등)
|
| 103 |
+
- 적절한 공감 전략 추천
|
| 104 |
+
"""
|
| 105 |
+
try:
|
| 106 |
+
logger.info(f"감정 분석 요청: '{request.text[:50]}...'")
|
| 107 |
+
|
| 108 |
+
response = await openai_client.analyze_emotion_and_context(
|
| 109 |
+
text=request.text,
|
| 110 |
+
additional_context=request.context
|
| 111 |
+
)
|
| 112 |
+
|
| 113 |
+
return response
|
| 114 |
+
|
| 115 |
+
except Exception as e:
|
| 116 |
+
logger.error(f"감정 분석 실패: {e}")
|
| 117 |
+
raise HTTPException(
|
| 118 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 119 |
+
detail=f"감정 분석 중 오류가 발생했습니다: {str(e)}"
|
| 120 |
+
)
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
@router.post("/react-response")
|
| 124 |
+
async def generate_react_response(
|
| 125 |
+
user_message: str,
|
| 126 |
+
similar_contexts: List[Dict[str, Any]] = None,
|
| 127 |
+
emotion: str = None,
|
| 128 |
+
relationship: str = None,
|
| 129 |
+
openai_client = Depends(get_openai_client)
|
| 130 |
+
):
|
| 131 |
+
"""
|
| 132 |
+
🧠 ReAct 패턴 응답 생성
|
| 133 |
+
|
| 134 |
+
- Thought → Action → Observation → Response
|
| 135 |
+
- 단계별 추론 과정 포함
|
| 136 |
+
- 유사 맥락 정보 활용
|
| 137 |
+
"""
|
| 138 |
+
try:
|
| 139 |
+
logger.info(f"ReAct 응답 요청: '{user_message[:50]}...'")
|
| 140 |
+
|
| 141 |
+
response_text, react_steps = await openai_client.generate_react_response(
|
| 142 |
+
user_message=user_message,
|
| 143 |
+
similar_contexts=similar_contexts or [],
|
| 144 |
+
emotion=emotion,
|
| 145 |
+
relationship=relationship
|
| 146 |
+
)
|
| 147 |
+
|
| 148 |
+
return {
|
| 149 |
+
"response": response_text,
|
| 150 |
+
"react_steps": react_steps,
|
| 151 |
+
"metadata": {
|
| 152 |
+
"emotion": emotion,
|
| 153 |
+
"relationship": relationship,
|
| 154 |
+
"context_count": len(similar_contexts) if similar_contexts else 0
|
| 155 |
+
}
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
except Exception as e:
|
| 159 |
+
logger.error(f"ReAct 응답 생성 실패: {e}")
|
| 160 |
+
raise HTTPException(
|
| 161 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 162 |
+
detail=f"ReAct 응답 생성 중 오류가 발생했습니다: {str(e)}"
|
| 163 |
+
)
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
@router.get("/models")
|
| 167 |
+
async def list_available_models():
|
| 168 |
+
"""
|
| 169 |
+
📋 사용 가능한 모델 목록
|
| 170 |
+
|
| 171 |
+
- 지원하는 OpenAI 모델들
|
| 172 |
+
- 각 모델의 특징 및 사용 권장사항
|
| 173 |
+
"""
|
| 174 |
+
return {
|
| 175 |
+
"available_models": [
|
| 176 |
+
{
|
| 177 |
+
"name": "gpt-4",
|
| 178 |
+
"description": "가장 강력한 모델, 복잡한 추론에 최적",
|
| 179 |
+
"recommended_for": ["청소년 공감 상담", "복잡한 맥락 이해"],
|
| 180 |
+
"max_tokens": 8192,
|
| 181 |
+
"cost": "높음"
|
| 182 |
+
},
|
| 183 |
+
{
|
| 184 |
+
"name": "gpt-4-turbo",
|
| 185 |
+
"description": "빠르고 효율적인 GPT-4 버전",
|
| 186 |
+
"recommended_for": ["실시간 채팅", "일반적인 상담"],
|
| 187 |
+
"max_tokens": 128000,
|
| 188 |
+
"cost": "중간"
|
| 189 |
+
},
|
| 190 |
+
{
|
| 191 |
+
"name": "gpt-3.5-turbo",
|
| 192 |
+
"description": "빠르고 경제적인 모델",
|
| 193 |
+
"recommended_for": ["간단한 질문", "테스트용"],
|
| 194 |
+
"max_tokens": 4096,
|
| 195 |
+
"cost": "낮음"
|
| 196 |
+
}
|
| 197 |
+
],
|
| 198 |
+
"current_default": "gpt-4",
|
| 199 |
+
"recommendation": "청소년 공감형 상담에는 gpt-4를 권장합니다"
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
@router.get("/health", response_model=SystemHealthCheck)
|
| 204 |
+
async def openai_health_check(openai_client = Depends(get_openai_client)):
|
| 205 |
+
"""
|
| 206 |
+
💊 OpenAI 서비스 헬스 체크
|
| 207 |
+
|
| 208 |
+
- API 연결 상태 확인
|
| 209 |
+
- 응답 시간 측정
|
| 210 |
+
- 서비스 가용성 점검
|
| 211 |
+
"""
|
| 212 |
+
try:
|
| 213 |
+
import time
|
| 214 |
+
start_time = time.time()
|
| 215 |
+
|
| 216 |
+
# 간단한 테스트 요청으로 연결 확인
|
| 217 |
+
test_response = await openai_client.create_completion(
|
| 218 |
+
messages=[{"role": "user", "content": "Hello"}],
|
| 219 |
+
max_tokens=5,
|
| 220 |
+
temperature=0
|
| 221 |
+
)
|
| 222 |
+
|
| 223 |
+
response_time_ms = (time.time() - start_time) * 1000
|
| 224 |
+
|
| 225 |
+
return SystemHealthCheck(
|
| 226 |
+
status="healthy",
|
| 227 |
+
services={
|
| 228 |
+
"openai_api": True,
|
| 229 |
+
"gpt4_model": True,
|
| 230 |
+
"embedding_generation": True
|
| 231 |
+
},
|
| 232 |
+
response_time_ms=response_time_ms,
|
| 233 |
+
version="1.0.0"
|
| 234 |
+
)
|
| 235 |
+
|
| 236 |
+
except Exception as e:
|
| 237 |
+
logger.error(f"OpenAI 헬스 체크 실패: {e}")
|
| 238 |
+
return SystemHealthCheck(
|
| 239 |
+
status="unhealthy",
|
| 240 |
+
services={
|
| 241 |
+
"openai_api": False,
|
| 242 |
+
"gpt4_model": False,
|
| 243 |
+
"embedding_generation": False
|
| 244 |
+
},
|
| 245 |
+
response_time_ms=0.0,
|
| 246 |
+
version="1.0.0"
|
| 247 |
+
)
|
| 248 |
+
|
| 249 |
+
|
| 250 |
+
@router.get("/usage-stats")
|
| 251 |
+
async def get_usage_stats():
|
| 252 |
+
"""
|
| 253 |
+
📊 OpenAI API 사용 통계
|
| 254 |
+
|
| 255 |
+
- 토큰 사용량 추정
|
| 256 |
+
- 비용 관련 정보
|
| 257 |
+
"""
|
| 258 |
+
return {
|
| 259 |
+
"current_session": {
|
| 260 |
+
"requests_made": "실시간 추적 필요",
|
| 261 |
+
"tokens_used": "실시간 추적 필요",
|
| 262 |
+
"estimated_cost": "실시간 추적 필요"
|
| 263 |
+
},
|
| 264 |
+
"cost_info": {
|
| 265 |
+
"gpt-4": {
|
| 266 |
+
"input_per_1k_tokens": "$0.03",
|
| 267 |
+
"output_per_1k_tokens": "$0.06"
|
| 268 |
+
},
|
| 269 |
+
"gpt-4-turbo": {
|
| 270 |
+
"input_per_1k_tokens": "$0.01",
|
| 271 |
+
"output_per_1k_tokens": "$0.03"
|
| 272 |
+
},
|
| 273 |
+
"gpt-3.5-turbo": {
|
| 274 |
+
"input_per_1k_tokens": "$0.0015",
|
| 275 |
+
"output_per_1k_tokens": "$0.002"
|
| 276 |
+
}
|
| 277 |
+
},
|
| 278 |
+
"optimization_tips": [
|
| 279 |
+
"적절한 max_tokens 설정으로 비용 절약",
|
| 280 |
+
"간단한 작업은 gpt-3.5-turbo 사용",
|
| 281 |
+
"시스템 프롬프트 최적화로 토큰 절약",
|
| 282 |
+
"불필요한 대화 히스토리 제거"
|
| 283 |
+
]
|
| 284 |
+
}
|
| 285 |
+
|
| 286 |
+
|
| 287 |
+
@router.post("/test-empathy")
|
| 288 |
+
async def test_empathy_response(
|
| 289 |
+
test_message: str = "친구가 나를 무시하는 것 같아서 기분이 나빠",
|
| 290 |
+
openai_client = Depends(get_openai_client)
|
| 291 |
+
):
|
| 292 |
+
"""
|
| 293 |
+
🧪 공감형 응답 테스트
|
| 294 |
+
|
| 295 |
+
- 청소년 공감형 시스템의 응답 품질 테스트
|
| 296 |
+
- 다양한 테스트 케이스 제공
|
| 297 |
+
"""
|
| 298 |
+
try:
|
| 299 |
+
# 감정 분석
|
| 300 |
+
emotion_result = await openai_client.analyze_emotion_and_context(test_message)
|
| 301 |
+
|
| 302 |
+
# 공감형 응답 생성
|
| 303 |
+
empathy_response = await openai_client.create_teen_empathy_response(test_message)
|
| 304 |
+
|
| 305 |
+
# ReAct 응답 생성
|
| 306 |
+
react_response, react_steps = await openai_client.generate_react_response(
|
| 307 |
+
user_message=test_message,
|
| 308 |
+
emotion=emotion_result.primary_emotion.value,
|
| 309 |
+
relationship=emotion_result.relationship_context.value if emotion_result.relationship_context else None
|
| 310 |
+
)
|
| 311 |
+
|
| 312 |
+
return {
|
| 313 |
+
"test_input": test_message,
|
| 314 |
+
"emotion_analysis": {
|
| 315 |
+
"primary_emotion": emotion_result.primary_emotion.value,
|
| 316 |
+
"confidence": emotion_result.emotion_confidence,
|
| 317 |
+
"relationship": emotion_result.relationship_context.value if emotion_result.relationship_context else None,
|
| 318 |
+
"strategies": [s.value for s in emotion_result.recommended_strategies]
|
| 319 |
+
},
|
| 320 |
+
"empathy_response": empathy_response,
|
| 321 |
+
"react_response": {
|
| 322 |
+
"response": react_response,
|
| 323 |
+
"steps": react_steps
|
| 324 |
+
},
|
| 325 |
+
"test_info": {
|
| 326 |
+
"response_quality": "수동 평가 필요",
|
| 327 |
+
"empathy_level": "수동 평가 필요",
|
| 328 |
+
"actionability": "수동 평가 필요"
|
| 329 |
+
}
|
| 330 |
+
}
|
| 331 |
+
|
| 332 |
+
except Exception as e:
|
| 333 |
+
logger.error(f"공감 응답 테스트 실패: {e}")
|
| 334 |
+
raise HTTPException(
|
| 335 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 336 |
+
detail=f"테스트 실행 중 오류가 발생했습니다: {str(e)}"
|
| 337 |
+
)
|
src/api/vector.py
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Vector Store API 라우터
|
| 3 |
+
ChromaDB 벡터 스토어 관련 엔드포인트들
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from fastapi import APIRouter, HTTPException, Depends, status
|
| 7 |
+
from typing import List
|
| 8 |
+
import time
|
| 9 |
+
from loguru import logger
|
| 10 |
+
|
| 11 |
+
from ..core.vector_store import get_vector_store
|
| 12 |
+
from ..models.vector_models import (
|
| 13 |
+
VectorSearchRequest, VectorSearchResponse,
|
| 14 |
+
DocumentAddRequest, DocumentAddResponse,
|
| 15 |
+
VectorStoreStats, SearchResult
|
| 16 |
+
)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
router = APIRouter()
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
@router.post("/search", response_model=VectorSearchResponse)
|
| 23 |
+
async def search_vectors(
|
| 24 |
+
request: VectorSearchRequest,
|
| 25 |
+
vector_store = Depends(get_vector_store)
|
| 26 |
+
):
|
| 27 |
+
"""
|
| 28 |
+
🔍 벡터 유사도 검색
|
| 29 |
+
|
| 30 |
+
- 쿼리와 유사한 문서들을 벡터 검색으로 찾기
|
| 31 |
+
- 감정, 관계 등 메타데이터 필터링 지원
|
| 32 |
+
- top_k 개수만큼 결과 반환
|
| 33 |
+
"""
|
| 34 |
+
try:
|
| 35 |
+
logger.info(f"벡터 검색 요청: '{request.query[:50]}...', top_k: {request.top_k}")
|
| 36 |
+
start_time = time.time()
|
| 37 |
+
|
| 38 |
+
# 벡터 검색 실행
|
| 39 |
+
results = await vector_store.search(
|
| 40 |
+
query=request.query,
|
| 41 |
+
top_k=request.top_k,
|
| 42 |
+
filter_metadata=request.filter_metadata
|
| 43 |
+
)
|
| 44 |
+
|
| 45 |
+
search_time_ms = (time.time() - start_time) * 1000
|
| 46 |
+
|
| 47 |
+
return VectorSearchResponse(
|
| 48 |
+
results=results,
|
| 49 |
+
query=request.query,
|
| 50 |
+
total_results=len(results),
|
| 51 |
+
search_time_ms=search_time_ms
|
| 52 |
+
)
|
| 53 |
+
|
| 54 |
+
except Exception as e:
|
| 55 |
+
logger.error(f"벡터 검색 실패: {e}")
|
| 56 |
+
raise HTTPException(
|
| 57 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 58 |
+
detail=f"벡터 검색 중 오류가 발생했습니다: {str(e)}"
|
| 59 |
+
)
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
@router.post("/documents", response_model=DocumentAddResponse)
|
| 63 |
+
async def add_documents(
|
| 64 |
+
request: DocumentAddRequest,
|
| 65 |
+
vector_store = Depends(get_vector_store)
|
| 66 |
+
):
|
| 67 |
+
"""
|
| 68 |
+
📝 문서 추가
|
| 69 |
+
|
| 70 |
+
- 새 문서들을 벡터 DB에 추가
|
| 71 |
+
- 자동으로 임베딩 생성 및 인덱싱
|
| 72 |
+
- 배치 처리로 효율적 추가
|
| 73 |
+
"""
|
| 74 |
+
try:
|
| 75 |
+
logger.info(f"문서 추가 요청: {len(request.documents)}개")
|
| 76 |
+
start_time = time.time()
|
| 77 |
+
|
| 78 |
+
# 문서 추가 실행
|
| 79 |
+
document_ids = await vector_store.add_documents(request.documents)
|
| 80 |
+
|
| 81 |
+
processing_time_ms = (time.time() - start_time) * 1000
|
| 82 |
+
|
| 83 |
+
return DocumentAddResponse(
|
| 84 |
+
success=True,
|
| 85 |
+
added_count=len(document_ids),
|
| 86 |
+
document_ids=document_ids,
|
| 87 |
+
processing_time_ms=processing_time_ms,
|
| 88 |
+
errors=[]
|
| 89 |
+
)
|
| 90 |
+
|
| 91 |
+
except Exception as e:
|
| 92 |
+
logger.error(f"문서 추가 실패: {e}")
|
| 93 |
+
return DocumentAddResponse(
|
| 94 |
+
success=False,
|
| 95 |
+
added_count=0,
|
| 96 |
+
document_ids=[],
|
| 97 |
+
processing_time_ms=0,
|
| 98 |
+
errors=[str(e)]
|
| 99 |
+
)
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
@router.get("/stats", response_model=VectorStoreStats)
|
| 103 |
+
async def get_vector_stats(vector_store = Depends(get_vector_store)):
|
| 104 |
+
"""
|
| 105 |
+
📊 벡터 스토어 통계
|
| 106 |
+
|
| 107 |
+
- 총 문서 수, 컬렉션 정보
|
| 108 |
+
- 임베딩 모델 정보
|
| 109 |
+
- 시스템 상태 확인
|
| 110 |
+
"""
|
| 111 |
+
try:
|
| 112 |
+
stats = await vector_store.get_collection_stats()
|
| 113 |
+
return stats
|
| 114 |
+
|
| 115 |
+
except Exception as e:
|
| 116 |
+
logger.error(f"통계 조회 실패: {e}")
|
| 117 |
+
raise HTTPException(
|
| 118 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 119 |
+
detail=f"통계 조회 중 오류가 발생했습니다: {str(e)}"
|
| 120 |
+
)
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
@router.delete("/documents/{document_id}")
|
| 124 |
+
async def delete_document(
|
| 125 |
+
document_id: str,
|
| 126 |
+
vector_store = Depends(get_vector_store)
|
| 127 |
+
):
|
| 128 |
+
"""
|
| 129 |
+
🗑️ 문서 삭제
|
| 130 |
+
|
| 131 |
+
- 특정 문서를 벡터 DB에서 삭제
|
| 132 |
+
"""
|
| 133 |
+
try:
|
| 134 |
+
success = await vector_store.delete_documents([document_id])
|
| 135 |
+
|
| 136 |
+
if success:
|
| 137 |
+
return {"message": f"문서 {document_id} 삭제 완료", "success": True}
|
| 138 |
+
else:
|
| 139 |
+
raise HTTPException(
|
| 140 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 141 |
+
detail=f"문서 {document_id}를 찾을 수 없습니다"
|
| 142 |
+
)
|
| 143 |
+
|
| 144 |
+
except HTTPException:
|
| 145 |
+
raise
|
| 146 |
+
except Exception as e:
|
| 147 |
+
logger.error(f"문서 삭제 실패: {e}")
|
| 148 |
+
raise HTTPException(
|
| 149 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 150 |
+
detail=f"문서 삭제 중 오류가 발생했습니다: {str(e)}"
|
| 151 |
+
)
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
@router.post("/clear")
|
| 155 |
+
async def clear_collection(vector_store = Depends(get_vector_store)):
|
| 156 |
+
"""
|
| 157 |
+
⚠️ 컬렉션 초기화
|
| 158 |
+
|
| 159 |
+
- 모든 문서 삭제 및 컬렉션 초기화
|
| 160 |
+
- 주의: 모든 데이터가 삭제됩니다!
|
| 161 |
+
"""
|
| 162 |
+
try:
|
| 163 |
+
success = await vector_store.clear_collection()
|
| 164 |
+
|
| 165 |
+
if success:
|
| 166 |
+
return {
|
| 167 |
+
"message": "컬렉션이 성공적으로 초기화되었습니다",
|
| 168 |
+
"success": True,
|
| 169 |
+
"warning": "모든 데이터가 삭제되었습니다"
|
| 170 |
+
}
|
| 171 |
+
else:
|
| 172 |
+
raise HTTPException(
|
| 173 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 174 |
+
detail="컬렉션 초기화에 실패했습니다"
|
| 175 |
+
)
|
| 176 |
+
|
| 177 |
+
except Exception as e:
|
| 178 |
+
logger.error(f"컬렉션 초기화 실패: {e}")
|
| 179 |
+
raise HTTPException(
|
| 180 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 181 |
+
detail=f"컬렉션 초기화 중 오류가 발생했습니다: {str(e)}"
|
| 182 |
+
)
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
@router.get("/health")
|
| 186 |
+
async def vector_health_check(vector_store = Depends(get_vector_store)):
|
| 187 |
+
"""
|
| 188 |
+
💊 벡터 스토어 헬스 체크
|
| 189 |
+
|
| 190 |
+
- 벡터 DB 연결 상태 확인
|
| 191 |
+
- 임베딩 모델 상태 확인
|
| 192 |
+
"""
|
| 193 |
+
try:
|
| 194 |
+
stats = await vector_store.get_collection_stats()
|
| 195 |
+
|
| 196 |
+
health_status = {
|
| 197 |
+
"status": "healthy" if stats.status == "healthy" else "unhealthy",
|
| 198 |
+
"collection_name": stats.collection_name,
|
| 199 |
+
"total_documents": stats.total_documents,
|
| 200 |
+
"embedding_model": stats.embedding_model,
|
| 201 |
+
"database_path": stats.database_path,
|
| 202 |
+
"checks": {
|
| 203 |
+
"chromadb_connection": True,
|
| 204 |
+
"embedding_model_loaded": stats.embedding_dimension is not None,
|
| 205 |
+
"collection_accessible": stats.total_documents >= 0
|
| 206 |
+
},
|
| 207 |
+
"last_updated": stats.last_updated
|
| 208 |
+
}
|
| 209 |
+
|
| 210 |
+
return health_status
|
| 211 |
+
|
| 212 |
+
except Exception as e:
|
| 213 |
+
logger.error(f"헬스 체크 실패: {e}")
|
| 214 |
+
return {
|
| 215 |
+
"status": "unhealthy",
|
| 216 |
+
"error": str(e),
|
| 217 |
+
"checks": {
|
| 218 |
+
"chromadb_connection": False,
|
| 219 |
+
"embedding_model_loaded": False,
|
| 220 |
+
"collection_accessible": False
|
| 221 |
+
}
|
| 222 |
+
}
|
| 223 |
+
|
| 224 |
+
|
| 225 |
+
@router.get("/search-demo")
|
| 226 |
+
async def search_demo():
|
| 227 |
+
"""
|
| 228 |
+
🎯 검색 데모 쿼리 예시
|
| 229 |
+
|
| 230 |
+
- 테스트용 검색 쿼리들
|
| 231 |
+
- API 사용법 가이드
|
| 232 |
+
"""
|
| 233 |
+
return {
|
| 234 |
+
"demo_queries": [
|
| 235 |
+
{
|
| 236 |
+
"description": "기본 검색",
|
| 237 |
+
"query": "친구와 싸웠어요",
|
| 238 |
+
"example_request": {
|
| 239 |
+
"query": "친구와 싸웠어요",
|
| 240 |
+
"top_k": 5
|
| 241 |
+
}
|
| 242 |
+
},
|
| 243 |
+
{
|
| 244 |
+
"description": "감정 필터 검색",
|
| 245 |
+
"query": "학교에서 스트레스 받아",
|
| 246 |
+
"example_request": {
|
| 247 |
+
"query": "학교에서 스트레스 받아",
|
| 248 |
+
"top_k": 3,
|
| 249 |
+
"filter_metadata": {
|
| 250 |
+
"emotion": "분노"
|
| 251 |
+
}
|
| 252 |
+
}
|
| 253 |
+
},
|
| 254 |
+
{
|
| 255 |
+
"description": "관계 맥락 검색",
|
| 256 |
+
"query": "잔소리 때문에 힘들어",
|
| 257 |
+
"example_request": {
|
| 258 |
+
"query": "잔소리 때문에 힘들어",
|
| 259 |
+
"top_k": 5,
|
| 260 |
+
"filter_metadata": {
|
| 261 |
+
"relationship": "부모님",
|
| 262 |
+
"data_source": "aihub"
|
| 263 |
+
}
|
| 264 |
+
}
|
| 265 |
+
}
|
| 266 |
+
],
|
| 267 |
+
"usage_tips": [
|
| 268 |
+
"구체적인 상황을 포함한 쿼리가 더 좋은 결과를 제공합니다",
|
| 269 |
+
"감정과 관계 맥락을 필터로 활용하면 정확도가 높아집니다",
|
| 270 |
+
"top_k는 1-20 사이의 값을 권장합니다"
|
| 271 |
+
]
|
| 272 |
+
}
|
src/core/__init__.py
ADDED
|
File without changes
|
src/core/vector_store.py
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
ChromaDB 기반 Vector Store - 핵심 기능만
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import chromadb
|
| 6 |
+
from chromadb.config import Settings
|
| 7 |
+
from sentence_transformers import SentenceTransformer
|
| 8 |
+
from typing import List, Dict, Any, Optional
|
| 9 |
+
from loguru import logger
|
| 10 |
+
import os
|
| 11 |
+
import uuid
|
| 12 |
+
import time
|
| 13 |
+
from datetime import datetime
|
| 14 |
+
|
| 15 |
+
from ..models.vector_models import SearchResult, DocumentInput, VectorStoreStats
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class ChromaVectorStore:
|
| 19 |
+
"""ChromaDB 기반 Vector Store"""
|
| 20 |
+
|
| 21 |
+
def __init__(self, collection_name: str = "teen_empathy_chat"):
|
| 22 |
+
self.collection_name = collection_name
|
| 23 |
+
self.client = None
|
| 24 |
+
self.collection = None
|
| 25 |
+
self.embedding_model = None
|
| 26 |
+
self.model_name = "jhgan/ko-sbert-multitask"
|
| 27 |
+
|
| 28 |
+
async def initialize(self):
|
| 29 |
+
"""ChromaDB 및 임베딩 모델 초기화"""
|
| 30 |
+
try:
|
| 31 |
+
logger.info("ChromaDB Vector Store 초기화 시작...")
|
| 32 |
+
|
| 33 |
+
# ChromaDB 클라이언트 생성
|
| 34 |
+
db_path = os.getenv("CHROMADB_PATH", "./data/chromadb")
|
| 35 |
+
os.makedirs(db_path, exist_ok=True)
|
| 36 |
+
|
| 37 |
+
self.client = chromadb.PersistentClient(
|
| 38 |
+
path=db_path,
|
| 39 |
+
settings=Settings(allow_reset=True, anonymized_telemetry=False)
|
| 40 |
+
)
|
| 41 |
+
|
| 42 |
+
# 한국어 임베딩 모델 로드
|
| 43 |
+
logger.info(f"한국어 임베딩 모델 로드 중: {self.model_name}")
|
| 44 |
+
self.embedding_model = SentenceTransformer(self.model_name)
|
| 45 |
+
logger.info(f"임베딩 모델 로드 완료 - 차원: {self.embedding_model.get_sentence_embedding_dimension()}")
|
| 46 |
+
|
| 47 |
+
# 컬렉션 생성/연결
|
| 48 |
+
try:
|
| 49 |
+
self.collection = self.client.get_collection(name=self.collection_name)
|
| 50 |
+
logger.info(f"기존 컬렉션 연결: {self.collection_name}")
|
| 51 |
+
except ValueError:
|
| 52 |
+
self.collection = self.client.create_collection(
|
| 53 |
+
name=self.collection_name,
|
| 54 |
+
metadata={
|
| 55 |
+
"description": "Teen empathy conversation embeddings",
|
| 56 |
+
"embedding_model": self.model_name,
|
| 57 |
+
"created_at": datetime.now().isoformat()
|
| 58 |
+
}
|
| 59 |
+
)
|
| 60 |
+
logger.info(f"새 컬렉션 생성: {self.collection_name}")
|
| 61 |
+
|
| 62 |
+
logger.info("✅ ChromaDB Vector Store 초기화 완료")
|
| 63 |
+
|
| 64 |
+
except Exception as e:
|
| 65 |
+
logger.error(f"❌ ChromaDB 초기화 실패: {e}")
|
| 66 |
+
raise
|
| 67 |
+
|
| 68 |
+
def create_embeddings(self, texts: List[str]) -> List[List[float]]:
|
| 69 |
+
"""한국어 임베딩 생성"""
|
| 70 |
+
try:
|
| 71 |
+
if not self.embedding_model:
|
| 72 |
+
raise ValueError("임베딩 모델이 초기화되지 않았습니다")
|
| 73 |
+
|
| 74 |
+
logger.info(f"임베딩 생성 중: {len(texts)}개 텍스트")
|
| 75 |
+
embeddings = self.embedding_model.encode(texts, convert_to_numpy=True)
|
| 76 |
+
embeddings_list = embeddings.tolist()
|
| 77 |
+
logger.info(f"✅ 임베딩 생성 완료: {len(embeddings_list)}개")
|
| 78 |
+
|
| 79 |
+
return embeddings_list
|
| 80 |
+
|
| 81 |
+
except Exception as e:
|
| 82 |
+
logger.error(f"❌ 임베딩 생성 실패: {e}")
|
| 83 |
+
raise
|
| 84 |
+
|
| 85 |
+
async def add_documents(self, documents: List[DocumentInput]) -> List[str]:
|
| 86 |
+
"""문서들을 Vector DB에 추가"""
|
| 87 |
+
try:
|
| 88 |
+
if not self.collection:
|
| 89 |
+
raise ValueError("컬렉션이 초기화되지 않았습니다")
|
| 90 |
+
|
| 91 |
+
logger.info(f"문서 추가 시작: {len(documents)}개")
|
| 92 |
+
|
| 93 |
+
# 텍스트와 메타데이터 분리
|
| 94 |
+
texts = [doc.content for doc in documents]
|
| 95 |
+
metadatas = []
|
| 96 |
+
document_ids = []
|
| 97 |
+
|
| 98 |
+
for doc in documents:
|
| 99 |
+
doc_id = doc.document_id or str(uuid.uuid4())
|
| 100 |
+
document_ids.append(doc_id)
|
| 101 |
+
|
| 102 |
+
metadata = doc.metadata.copy() if doc.metadata else {}
|
| 103 |
+
metadata.update({
|
| 104 |
+
"timestamp": datetime.now().isoformat(),
|
| 105 |
+
"content_length": len(doc.content)
|
| 106 |
+
})
|
| 107 |
+
metadatas.append(metadata)
|
| 108 |
+
|
| 109 |
+
# 임베딩 생성
|
| 110 |
+
embeddings = self.create_embeddings(texts)
|
| 111 |
+
|
| 112 |
+
# ChromaDB에 추가
|
| 113 |
+
self.collection.add(
|
| 114 |
+
embeddings=embeddings,
|
| 115 |
+
documents=texts,
|
| 116 |
+
metadatas=metadatas,
|
| 117 |
+
ids=document_ids
|
| 118 |
+
)
|
| 119 |
+
|
| 120 |
+
logger.info(f"✅ 문서 {len(documents)}개 추가 완료")
|
| 121 |
+
return document_ids
|
| 122 |
+
|
| 123 |
+
except Exception as e:
|
| 124 |
+
logger.error(f"❌ 문서 추가 실패: {e}")
|
| 125 |
+
raise
|
| 126 |
+
|
| 127 |
+
async def search(self, query: str, top_k: int = 5,
|
| 128 |
+
filter_metadata: Optional[Dict[str, Any]] = None) -> List[SearchResult]:
|
| 129 |
+
"""유사도 기반 문서 검색"""
|
| 130 |
+
try:
|
| 131 |
+
if not self.collection:
|
| 132 |
+
raise ValueError("컬렉션이 초기화되지 않았습니다")
|
| 133 |
+
|
| 134 |
+
start_time = time.time()
|
| 135 |
+
logger.info(f"검색 시작 - 쿼리: '{query[:50]}...', top_k: {top_k}")
|
| 136 |
+
|
| 137 |
+
# 쿼리 임베딩 생성
|
| 138 |
+
query_embedding = self.create_embeddings([query])[0]
|
| 139 |
+
|
| 140 |
+
# ChromaDB 검색
|
| 141 |
+
search_kwargs = {
|
| 142 |
+
"query_embeddings": [query_embedding],
|
| 143 |
+
"n_results": top_k,
|
| 144 |
+
"include": ["documents", "metadatas", "distances"]
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
if filter_metadata:
|
| 148 |
+
search_kwargs["where"] = filter_metadata
|
| 149 |
+
|
| 150 |
+
results = self.collection.query(**search_kwargs)
|
| 151 |
+
|
| 152 |
+
# 결과 포맷팅
|
| 153 |
+
search_results = []
|
| 154 |
+
|
| 155 |
+
if results["documents"] and results["documents"][0]:
|
| 156 |
+
for i in range(len(results["documents"][0])):
|
| 157 |
+
distance = results["distances"][0][i]
|
| 158 |
+
|
| 159 |
+
# 유클리드 거리를 유사도로 변환
|
| 160 |
+
if distance <= 200:
|
| 161 |
+
similarity_score = 0.8 - (distance / 200) * 0.3
|
| 162 |
+
elif distance <= 300:
|
| 163 |
+
similarity_score = 0.5 - ((distance - 200) / 100) * 0.3
|
| 164 |
+
else:
|
| 165 |
+
similarity_score = max(0.01, 1000 / (distance + 100))
|
| 166 |
+
|
| 167 |
+
similarity_score = max(0.0, min(1.0, similarity_score))
|
| 168 |
+
|
| 169 |
+
search_results.append(SearchResult(
|
| 170 |
+
content=results["documents"][0][i],
|
| 171 |
+
metadata=results["metadatas"][0][i] if results["metadatas"][0] else {},
|
| 172 |
+
score=similarity_score,
|
| 173 |
+
document_id=f"result_{i}"
|
| 174 |
+
))
|
| 175 |
+
|
| 176 |
+
search_time = (time.time() - start_time) * 1000
|
| 177 |
+
logger.info(f"✅ 검색 완료: {len(search_results)}개 결과 ({search_time:.2f}ms)")
|
| 178 |
+
return search_results
|
| 179 |
+
|
| 180 |
+
except Exception as e:
|
| 181 |
+
logger.error(f"❌ 검색 실패: {e}")
|
| 182 |
+
raise
|
| 183 |
+
|
| 184 |
+
async def get_collection_stats(self) -> VectorStoreStats:
|
| 185 |
+
"""컬렉션 통계 정보"""
|
| 186 |
+
try:
|
| 187 |
+
if not self.collection:
|
| 188 |
+
raise ValueError("컬렉션이 초기화되지 않음")
|
| 189 |
+
|
| 190 |
+
count = self.collection.count()
|
| 191 |
+
|
| 192 |
+
return VectorStoreStats(
|
| 193 |
+
collection_name=self.collection_name,
|
| 194 |
+
total_documents=count,
|
| 195 |
+
embedding_model=self.model_name,
|
| 196 |
+
embedding_dimension=self.embedding_model.get_sentence_embedding_dimension() if self.embedding_model else None,
|
| 197 |
+
database_path=os.getenv("CHROMADB_PATH", "./data/chromadb"),
|
| 198 |
+
status="healthy" if count >= 0 else "error",
|
| 199 |
+
last_updated=datetime.now().isoformat()
|
| 200 |
+
)
|
| 201 |
+
|
| 202 |
+
except Exception as e:
|
| 203 |
+
logger.error(f"통계 조회 실패: {e}")
|
| 204 |
+
raise
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
# 전역 인스턴스
|
| 208 |
+
_vector_store_instance = None
|
| 209 |
+
|
| 210 |
+
async def get_vector_store() -> ChromaVectorStore:
|
| 211 |
+
"""Vector Store 싱글톤 인스턴스 반환"""
|
| 212 |
+
global _vector_store_instance
|
| 213 |
+
|
| 214 |
+
if _vector_store_instance is None:
|
| 215 |
+
collection_name = os.getenv("COLLECTION_NAME", "teen_empathy_chat")
|
| 216 |
+
_vector_store_instance = ChromaVectorStore(collection_name)
|
| 217 |
+
await _vector_store_instance.initialize()
|
| 218 |
+
|
| 219 |
+
return _vector_store_instance
|
src/models/__init__.py
ADDED
|
File without changes
|
src/models/function_models.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
OpenAI 및 기타 기능 관련 데이터 모델들
|
| 3 |
+
GPT-4 API 호출, 채팅, 감정 분석 등의 모델들
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from pydantic import BaseModel, Field
|
| 7 |
+
from typing import List, Optional, Dict, Any, Literal
|
| 8 |
+
from datetime import datetime
|
| 9 |
+
from enum import Enum
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class ChatRole(str, Enum):
|
| 13 |
+
"""채팅 역할 열거형"""
|
| 14 |
+
SYSTEM = "system"
|
| 15 |
+
USER = "user"
|
| 16 |
+
ASSISTANT = "assistant"
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class ChatMessage(BaseModel):
|
| 20 |
+
"""채팅 메시지 모델"""
|
| 21 |
+
role: ChatRole = Field(..., description="메시지 역할")
|
| 22 |
+
content: str = Field(..., description="메시지 내용", min_length=1)
|
| 23 |
+
timestamp: Optional[str] = Field(default_factory=lambda: datetime.now().isoformat(), description="메시지 시간")
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class OpenAICompletionRequest(BaseModel):
|
| 27 |
+
"""OpenAI 완성 요청 모델"""
|
| 28 |
+
messages: List[ChatMessage] = Field(..., description="대화 메시지 목록")
|
| 29 |
+
model: str = Field(default="gpt-4", description="사용할 모델")
|
| 30 |
+
temperature: float = Field(default=0.7, description="응답 창의성", ge=0, le=2)
|
| 31 |
+
max_tokens: int = Field(default=500, description="최대 토큰 수", ge=1, le=4000)
|
| 32 |
+
top_p: float = Field(default=1.0, description="확률 임계값", ge=0, le=1)
|
| 33 |
+
stream: bool = Field(default=False, description="스트리밍 여부")
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
class OpenAICompletionResponse(BaseModel):
|
| 37 |
+
"""OpenAI 완성 응답 모델"""
|
| 38 |
+
content: str = Field(..., description="생성된 응답 내용")
|
| 39 |
+
model: str = Field(..., description="사용된 모델")
|
| 40 |
+
tokens_used: int = Field(..., description="사용된 토큰 수")
|
| 41 |
+
processing_time_ms: float = Field(..., description="처리 시간 (밀리초)")
|
| 42 |
+
finish_reason: str = Field(..., description="완료 이유")
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
class EmotionType(str, Enum):
|
| 46 |
+
"""감정 유형 열거형"""
|
| 47 |
+
JOY = "기쁨"
|
| 48 |
+
CONFUSION = "당황"
|
| 49 |
+
ANGER = "분노"
|
| 50 |
+
ANXIETY = "불안"
|
| 51 |
+
HURT = "상처"
|
| 52 |
+
SADNESS = "슬픔"
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
class RelationshipType(str, Enum):
|
| 56 |
+
"""관계 유형 열거형"""
|
| 57 |
+
PARENT = "부모님"
|
| 58 |
+
FRIEND = "친구"
|
| 59 |
+
SIBLING = "형제자매"
|
| 60 |
+
CRUSH = "좋아하는 사람"
|
| 61 |
+
CLASSMATE = "동급생"
|
| 62 |
+
FAMILY = "가족"
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
class EmpathyStrategy(str, Enum):
|
| 66 |
+
"""공감 전략 열거형"""
|
| 67 |
+
ENCOURAGE = "격려"
|
| 68 |
+
AGREE = "동조"
|
| 69 |
+
COMFORT = "위로"
|
| 70 |
+
ADVISE = "조언"
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
class EmotionAnalysisRequest(BaseModel):
|
| 74 |
+
"""감정 분석 요청 모델"""
|
| 75 |
+
text: str = Field(..., description="분석할 텍스트", min_length=1, max_length=1000)
|
| 76 |
+
context: Optional[str] = Field(default=None, description="추가 맥락 정보")
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
class EmotionAnalysisResponse(BaseModel):
|
| 80 |
+
"""감정 분석 응답 모델"""
|
| 81 |
+
primary_emotion: EmotionType = Field(..., description="주요 감정")
|
| 82 |
+
emotion_confidence: float = Field(..., description="감정 신뢰도", ge=0, le=1)
|
| 83 |
+
relationship_context: Optional[RelationshipType] = Field(default=None, description="관계 맥락")
|
| 84 |
+
recommended_strategies: List[EmpathyStrategy] = Field(..., description="추천 공감 전략")
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
class TeenChatRequest(BaseModel):
|
| 88 |
+
"""청소년 채팅 요청 모델"""
|
| 89 |
+
message: str = Field(..., description="사용자 메시지", min_length=1, max_length=1000)
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
class ReActStep(BaseModel):
|
| 93 |
+
"""ReAct 추론 단계 모델"""
|
| 94 |
+
step_type: Literal["thought", "action", "observation"] = Field(..., description="단계 유형")
|
| 95 |
+
content: str = Field(..., description="단계 내용")
|
| 96 |
+
timestamp: str = Field(default_factory=lambda: datetime.now().isoformat(), description="단계 시간")
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
class TeenChatResponse(BaseModel):
|
| 100 |
+
"""청소년 채팅 응답 모델"""
|
| 101 |
+
response: str = Field(..., description="공감형 응답")
|
| 102 |
+
detected_emotion: EmotionType = Field(..., description="감지된 감정")
|
| 103 |
+
empathy_strategy: List[EmpathyStrategy] = Field(..., description="적용된 공감 전략")
|
| 104 |
+
similar_contexts: List[Dict[str, Any]] = Field(default=[], description="유사한 대화 맥락")
|
| 105 |
+
react_steps: Optional[List[ReActStep]] = Field(default=None, description="ReAct 추론 과정")
|
| 106 |
+
confidence_score: float = Field(..., description="응답 신뢰도", ge=0, le=1)
|
| 107 |
+
response_metadata: Dict[str, Any] = Field(default={}, description="응답 메타데이터")
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
class SystemHealthCheck(BaseModel):
|
| 111 |
+
"""시스템 헬스 체크 모델"""
|
| 112 |
+
status: Literal["healthy", "degraded", "unhealthy"] = Field(..., description="시스템 상태")
|
| 113 |
+
services: Dict[str, bool] = Field(..., description="서비스별 상태")
|
| 114 |
+
response_time_ms: float = Field(..., description="응답 시간 (밀리초)")
|
| 115 |
+
timestamp: str = Field(default_factory=lambda: datetime.now().isoformat(), description="체크 시간")
|
| 116 |
+
version: str = Field(..., description="시스템 버전")
|
src/models/vector_models.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Vector Store 관련 데이터 모델들
|
| 3 |
+
ChromaDB와 연동하는 Pydantic 모델들
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from pydantic import BaseModel, Field
|
| 7 |
+
from typing import Dict, List, Any, Optional
|
| 8 |
+
from datetime import datetime
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class DocumentInput(BaseModel):
|
| 12 |
+
"""벡터 DB에 저장할 문서 입력 모델"""
|
| 13 |
+
content: str = Field(..., description="문서 내용", min_length=1)
|
| 14 |
+
metadata: Optional[Dict[str, Any]] = Field(default={}, description="문서 메타데이터")
|
| 15 |
+
document_id: Optional[str] = Field(default=None, description="문서 고유 ID")
|
| 16 |
+
|
| 17 |
+
class Config:
|
| 18 |
+
json_schema_extra = {
|
| 19 |
+
"example": {
|
| 20 |
+
"content": "[불안] [친구] 친구가 나를 무시하는 것 같아서 속상해",
|
| 21 |
+
"metadata": {
|
| 22 |
+
"emotion": "불안",
|
| 23 |
+
"relationship": "친구",
|
| 24 |
+
"empathy_label": "위로",
|
| 25 |
+
"data_source": "aihub"
|
| 26 |
+
},
|
| 27 |
+
"document_id": "session_001"
|
| 28 |
+
}
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
class SearchResult(BaseModel):
|
| 33 |
+
"""벡터 검색 결과 모델"""
|
| 34 |
+
content: str = Field(..., description="검색된 문서 내용")
|
| 35 |
+
metadata: Dict[str, Any] = Field(default={}, description="문서 메타데이터")
|
| 36 |
+
score: float = Field(..., description="유사도 점수 (0~1)", ge=0, le=1)
|
| 37 |
+
document_id: str = Field(..., description="문서 고유 ID")
|
| 38 |
+
|
| 39 |
+
class Config:
|
| 40 |
+
json_schema_extra = {
|
| 41 |
+
"example": {
|
| 42 |
+
"content": "[불안] [친구] 친구가 나를 무시하는 것 같아서 속상해",
|
| 43 |
+
"metadata": {
|
| 44 |
+
"user_utterance": "친구가 나를 무시하는 것 같아서 속상해",
|
| 45 |
+
"system_response": "친구가 너를 무시한다고 느끼는 구체적인 상황이 있었나?",
|
| 46 |
+
"emotion": "불안",
|
| 47 |
+
"relationship": "친구",
|
| 48 |
+
"empathy_label": "위로"
|
| 49 |
+
},
|
| 50 |
+
"score": 0.95,
|
| 51 |
+
"document_id": "session_001"
|
| 52 |
+
}
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
class VectorSearchRequest(BaseModel):
|
| 57 |
+
"""벡터 검색 요청 모델"""
|
| 58 |
+
query: str = Field(..., description="검색 쿼리", min_length=1, max_length=500)
|
| 59 |
+
top_k: int = Field(default=5, description="반환할 결과 수", ge=1, le=20)
|
| 60 |
+
filter_metadata: Optional[Dict[str, Any]] = Field(default=None, description="메타데이터 필터")
|
| 61 |
+
include_scores: bool = Field(default=True, description="유사도 점수 포함 여부")
|
| 62 |
+
|
| 63 |
+
class Config:
|
| 64 |
+
json_schema_extra = {
|
| 65 |
+
"example": {
|
| 66 |
+
"query": "친구와 싸웠어요",
|
| 67 |
+
"top_k": 3,
|
| 68 |
+
"filter_metadata": {
|
| 69 |
+
"emotion": "분노",
|
| 70 |
+
"data_source": "aihub"
|
| 71 |
+
},
|
| 72 |
+
"include_scores": True
|
| 73 |
+
}
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
class VectorSearchResponse(BaseModel):
|
| 78 |
+
"""벡터 검색 응답 모델"""
|
| 79 |
+
results: List[SearchResult] = Field(..., description="검색 결과 목록")
|
| 80 |
+
query: str = Field(..., description="검색 쿼리")
|
| 81 |
+
total_results: int = Field(..., description="총 결과 수")
|
| 82 |
+
search_time_ms: float = Field(..., description="검색 소요 시간 (밀리초)")
|
| 83 |
+
|
| 84 |
+
class Config:
|
| 85 |
+
json_schema_extra = {
|
| 86 |
+
"example": {
|
| 87 |
+
"results": [
|
| 88 |
+
{
|
| 89 |
+
"content": "[분노] [친구] 친구와 싸워서 화가 나",
|
| 90 |
+
"metadata": {
|
| 91 |
+
"emotion": "분노",
|
| 92 |
+
"relationship": "친구",
|
| 93 |
+
"empathy_label": "위로"
|
| 94 |
+
},
|
| 95 |
+
"score": 0.92,
|
| 96 |
+
"document_id": "session_123"
|
| 97 |
+
}
|
| 98 |
+
],
|
| 99 |
+
"query": "친구와 싸웠어요",
|
| 100 |
+
"total_results": 1,
|
| 101 |
+
"search_time_ms": 45.2
|
| 102 |
+
}
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
class DocumentAddRequest(BaseModel):
|
| 107 |
+
"""문서 추가 요청 모델"""
|
| 108 |
+
documents: List[DocumentInput] = Field(..., description="추가할 문서들", min_items=1)
|
| 109 |
+
batch_size: int = Field(default=100, description="배치 크기", ge=1, le=1000)
|
| 110 |
+
|
| 111 |
+
class Config:
|
| 112 |
+
json_schema_extra = {
|
| 113 |
+
"example": {
|
| 114 |
+
"documents": [
|
| 115 |
+
{
|
| 116 |
+
"content": "[기쁨] [친구] 친구와 함께 시험을 잘 봤어요",
|
| 117 |
+
"metadata": {
|
| 118 |
+
"emotion": "기쁨",
|
| 119 |
+
"relationship": "친구",
|
| 120 |
+
"empathy_label": "격려"
|
| 121 |
+
}
|
| 122 |
+
}
|
| 123 |
+
],
|
| 124 |
+
"batch_size": 50
|
| 125 |
+
}
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
class DocumentAddResponse(BaseModel):
|
| 130 |
+
"""문서 추가 응답 모델"""
|
| 131 |
+
success: bool = Field(..., description="추가 성공 여부")
|
| 132 |
+
added_count: int = Field(..., description="추가된 문서 수")
|
| 133 |
+
document_ids: List[str] = Field(..., description="추가된 문서 ID 목록")
|
| 134 |
+
processing_time_ms: float = Field(..., description="처리 소요 시간 (밀리초)")
|
| 135 |
+
errors: List[str] = Field(default=[], description="오류 메시지 목록")
|
| 136 |
+
|
| 137 |
+
class Config:
|
| 138 |
+
json_schema_extra = {
|
| 139 |
+
"example": {
|
| 140 |
+
"success": True,
|
| 141 |
+
"added_count": 5,
|
| 142 |
+
"document_ids": ["doc_001", "doc_002", "doc_003"],
|
| 143 |
+
"processing_time_ms": 1250.5,
|
| 144 |
+
"errors": []
|
| 145 |
+
}
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
class VectorStoreStats(BaseModel):
|
| 150 |
+
"""벡터 스토어 통계 모델"""
|
| 151 |
+
collection_name: str = Field(..., description="컬렉션 이름")
|
| 152 |
+
total_documents: int = Field(..., description="총 문서 수")
|
| 153 |
+
embedding_model: str = Field(..., description="사용중인 임베딩 모델")
|
| 154 |
+
embedding_dimension: Optional[int] = Field(default=None, description="임베딩 차원")
|
| 155 |
+
database_path: str = Field(..., description="데이터베이스 경로")
|
| 156 |
+
status: str = Field(..., description="상태")
|
| 157 |
+
last_updated: str = Field(default_factory=lambda: datetime.now().isoformat(), description="마지막 업데이트")
|
| 158 |
+
|
| 159 |
+
class Config:
|
| 160 |
+
json_schema_extra = {
|
| 161 |
+
"example": {
|
| 162 |
+
"collection_name": "teen_empathy_chat",
|
| 163 |
+
"total_documents": 31821,
|
| 164 |
+
"embedding_model": "jhgan/ko-sbert-multitask",
|
| 165 |
+
"embedding_dimension": 768,
|
| 166 |
+
"database_path": "./data/chromadb",
|
| 167 |
+
"status": "healthy",
|
| 168 |
+
"last_updated": "2024-01-01T12:00:00"
|
| 169 |
+
}
|
| 170 |
+
}
|
src/services/__init__.py
ADDED
|
File without changes
|
src/services/aihub_processor.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
AI Hub 공감형 대화 데이터 처리기
|
| 3 |
+
"""
|
| 4 |
+
from typing import Dict, List, Optional
|
| 5 |
+
from loguru import logger
|
| 6 |
+
|
| 7 |
+
class TeenEmpathyDataProcessor:
|
| 8 |
+
def __init__(self, vector_store):
|
| 9 |
+
self.vector_store = vector_store
|
| 10 |
+
logger.info("TeenEmpathyDataProcessor 초기화 완료. Vector Store가 주입되었습니다.")
|
| 11 |
+
|
| 12 |
+
async def search_similar_contexts(self, query: str, emotion: Optional[str] = None,
|
| 13 |
+
relationship: Optional[str] = None, top_k: int = 3) -> List[Dict]:
|
| 14 |
+
"""
|
| 15 |
+
[수정됨] 원본 쿼리와 메타데이터 필터를 사용하여 유사한 대화 맥락을 정확하게 검색합니다.
|
| 16 |
+
"""
|
| 17 |
+
try:
|
| 18 |
+
# 1. 메타데이터 필터 구성 (ChromaDB의 올바른 $and 문법 사용)
|
| 19 |
+
conditions = []
|
| 20 |
+
if emotion: conditions.append({"emotion": {"$eq": emotion}})
|
| 21 |
+
if relationship: conditions.append({"relationship": {"$eq": relationship}})
|
| 22 |
+
|
| 23 |
+
search_filter = None
|
| 24 |
+
if len(conditions) > 1: search_filter = {"$and": conditions}
|
| 25 |
+
elif len(conditions) == 1: search_filter = conditions[0]
|
| 26 |
+
|
| 27 |
+
logger.info(f"🔍 벡터 검색 시작 - Query: '{query}', Filter: {search_filter}")
|
| 28 |
+
|
| 29 |
+
# 2. 원본 쿼리로 벡터 검색 실행
|
| 30 |
+
results = await self.vector_store.search(
|
| 31 |
+
query=query,
|
| 32 |
+
top_k=top_k,
|
| 33 |
+
filter_metadata=search_filter
|
| 34 |
+
)
|
| 35 |
+
|
| 36 |
+
formatted_results = [{
|
| 37 |
+
"user_utterance": r.metadata.get("user_utterance", ""),
|
| 38 |
+
"system_response": r.metadata.get("system_response", ""),
|
| 39 |
+
"emotion": r.metadata.get("emotion", ""),
|
| 40 |
+
"relationship": r.metadata.get("relationship", ""),
|
| 41 |
+
"empathy_label": r.metadata.get("empathy_label", ""),
|
| 42 |
+
"similarity_score": r.score
|
| 43 |
+
} for r in results]
|
| 44 |
+
|
| 45 |
+
formatted_results.sort(key=lambda x: x["similarity_score"], reverse=True)
|
| 46 |
+
logger.info(f"✅ 검색 완료: {len(formatted_results)}개 결과")
|
| 47 |
+
return formatted_results
|
| 48 |
+
|
| 49 |
+
except Exception as e:
|
| 50 |
+
logger.error(f"❌ 유사 사례 검색 실패: {e}")
|
| 51 |
+
return []
|
| 52 |
+
|
| 53 |
+
# 전역 인스턴스 관리
|
| 54 |
+
_processor_instance = None
|
| 55 |
+
async def get_teen_empathy_processor() -> TeenEmpathyDataProcessor:
|
| 56 |
+
global _processor_instance
|
| 57 |
+
if _processor_instance is None:
|
| 58 |
+
from ..core.vector_store import get_vector_store
|
| 59 |
+
vector_store = await get_vector_store()
|
| 60 |
+
_processor_instance = TeenEmpathyDataProcessor(vector_store=vector_store)
|
| 61 |
+
return _processor_instance
|
src/services/conversation_service.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
간단한 대화 저장 시스템 - SQLite
|
| 3 |
+
"""
|
| 4 |
+
import sqlite3
|
| 5 |
+
import json
|
| 6 |
+
import uuid
|
| 7 |
+
import os
|
| 8 |
+
from datetime import datetime
|
| 9 |
+
from typing import List, Dict
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
from contextlib import contextmanager
|
| 12 |
+
from loguru import logger
|
| 13 |
+
|
| 14 |
+
class ConversationService:
|
| 15 |
+
def __init__(self):
|
| 16 |
+
db_path = os.getenv("CONVERSATION_DB_PATH", "/app/data/conversations/conversations.db")
|
| 17 |
+
self.db_path = Path(db_path)
|
| 18 |
+
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
| 19 |
+
self._ensure_tables()
|
| 20 |
+
logger.info(f"✅ 대화 DB 초기화: {self.db_path}")
|
| 21 |
+
|
| 22 |
+
def _ensure_tables(self):
|
| 23 |
+
try:
|
| 24 |
+
with self._get_connection() as conn:
|
| 25 |
+
conn.executescript("""
|
| 26 |
+
CREATE TABLE IF NOT EXISTS conversations (
|
| 27 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 28 |
+
session_id TEXT NOT NULL,
|
| 29 |
+
role TEXT NOT NULL,
|
| 30 |
+
content TEXT NOT NULL,
|
| 31 |
+
timestamp TEXT NOT NULL
|
| 32 |
+
);
|
| 33 |
+
CREATE INDEX IF NOT EXISTS idx_conversations_session ON conversations(session_id);
|
| 34 |
+
""")
|
| 35 |
+
except Exception as e:
|
| 36 |
+
logger.error(f"❌ DB 테이블 초기화 실패: {e}")
|
| 37 |
+
raise
|
| 38 |
+
|
| 39 |
+
@contextmanager
|
| 40 |
+
def _get_connection(self):
|
| 41 |
+
conn = None
|
| 42 |
+
try:
|
| 43 |
+
conn = sqlite3.connect(self.db_path, timeout=15.0)
|
| 44 |
+
conn.row_factory = sqlite3.Row
|
| 45 |
+
yield conn
|
| 46 |
+
finally:
|
| 47 |
+
if conn:
|
| 48 |
+
conn.close()
|
| 49 |
+
|
| 50 |
+
async def get_or_create_session(self, session_id: str = None) -> str:
|
| 51 |
+
if session_id: return session_id
|
| 52 |
+
return f"session_{uuid.uuid4().hex[:12]}"
|
| 53 |
+
|
| 54 |
+
async def save_conversation_turn(self, session_id: str, user_message: str, assistant_response: str):
|
| 55 |
+
now = datetime.now().isoformat()
|
| 56 |
+
try:
|
| 57 |
+
with self._get_connection() as conn:
|
| 58 |
+
conn.execute("BEGIN")
|
| 59 |
+
conn.execute(
|
| 60 |
+
"INSERT INTO conversations (session_id, role, content, timestamp) VALUES (?, ?, ?, ?)",
|
| 61 |
+
(session_id, 'user', user_message, now)
|
| 62 |
+
)
|
| 63 |
+
conn.execute(
|
| 64 |
+
"INSERT INTO conversations (session_id, role, content, timestamp) VALUES (?, ?, ?, ?)",
|
| 65 |
+
(session_id, 'assistant', assistant_response, now)
|
| 66 |
+
)
|
| 67 |
+
conn.commit()
|
| 68 |
+
logger.info(f"💾 대화 턴 저장 완료: {session_id}")
|
| 69 |
+
except Exception as e:
|
| 70 |
+
logger.error(f"❌ 대화 턴 저장 실패: {e}")
|
| 71 |
+
conn.rollback()
|
| 72 |
+
|
| 73 |
+
async def get_conversation_history(self, session_id: str, limit: int = 6) -> List[Dict[str, str]]:
|
| 74 |
+
"""GPT 프롬프트에 사용하기 좋은 형태로 최근 대화 기록을 반환"""
|
| 75 |
+
history = []
|
| 76 |
+
try:
|
| 77 |
+
with self._get_connection() as conn:
|
| 78 |
+
rows = conn.execute("""
|
| 79 |
+
SELECT role, content FROM conversations
|
| 80 |
+
WHERE session_id = ? ORDER BY timestamp DESC LIMIT ?
|
| 81 |
+
""", (session_id, limit)).fetchall()
|
| 82 |
+
|
| 83 |
+
# 시간 역순으로 가져왔으므로 뒤집어서 시간 순으로 정렬
|
| 84 |
+
for row in reversed(rows):
|
| 85 |
+
history.append({"role": row['role'], "content": row['content']})
|
| 86 |
+
logger.info(f"📚 대화 기록 조회 완료: {session_id}, {len(history)}개 메시지")
|
| 87 |
+
except Exception as e:
|
| 88 |
+
logger.warning(f"대화 기록 조회 실패: {e}")
|
| 89 |
+
return history
|
| 90 |
+
|
| 91 |
+
_conversation_service_instance = None
|
| 92 |
+
async def get_conversation_service() -> ConversationService:
|
| 93 |
+
global _conversation_service_instance
|
| 94 |
+
if _conversation_service_instance is None:
|
| 95 |
+
_conversation_service_instance = ConversationService()
|
| 96 |
+
return _conversation_service_instance
|
src/services/openai_client.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
OpenAI GPT-4 클라이언트 - 최종 완성 버전
|
| 3 |
+
"""
|
| 4 |
+
import os
|
| 5 |
+
from typing import List, Dict, Tuple, Optional
|
| 6 |
+
from openai import AsyncOpenAI
|
| 7 |
+
from loguru import logger
|
| 8 |
+
from ..models.function_models import EmotionType, RelationshipType
|
| 9 |
+
|
| 10 |
+
class OpenAIClient:
|
| 11 |
+
def __init__(self):
|
| 12 |
+
self.client = None
|
| 13 |
+
self.api_key = os.getenv("OPENAI_API_KEY")
|
| 14 |
+
self.default_model = os.getenv("OPENAI_MODEL", "gpt-4")
|
| 15 |
+
self.teen_empathy_system_prompt = """
|
| 16 |
+
당신은 "마음이"라는 이름의 13-19세 청소년 전용 상담 AI입니다. 당신의 목표는 사용자의 말을 따뜻하게 들어주고 공감하며, 친한 친구처럼 반말로 대화하는 것입니다.
|
| 17 |
+
|
| 18 |
+
**[매우 중요] 핵심 규칙:**
|
| 19 |
+
- **페르소나 절대 유지:** 너는 반드시 친한 친구처럼, 따뜻하고 다정한 **반말**로 대화해야 해. **절대로 존댓말을 사용하면 안 돼!**
|
| 20 |
+
- **맥락 기억:** 이전 대화 내용을 반드시 기억하고, 그 흐름에 맞춰 자연스럽게 대화를 이어가야 해.
|
| 21 |
+
- **공감 우선:** 조언보다는 먼저 사용자의 감정을 알아주고 공감하는 말을 해줘. (예: "정말 속상했겠다.", "네 마음 충분히 이해돼.")
|
| 22 |
+
- **영어 절대 금지:** 답변은 반드시 한글로만 생성해야 해.
|
| 23 |
+
"""
|
| 24 |
+
self.conversion_map = { "자기야": "너", "당신": "너", "직장": "학교", "회사": "학교", "업무": "공부", "동료": "친구", "상사": "선생님", "하세요": "해", "어떠세요": "어때", "해보세요": "해봐", "~ㅂ니다": "~야", "~습니다": "~어" }
|
| 25 |
+
|
| 26 |
+
async def initialize(self):
|
| 27 |
+
if not self.api_key or "your_" in self.api_key.lower(): raise ValueError("올바른 OpenAI API 키를 설정해주세요")
|
| 28 |
+
self.client = AsyncOpenAI(api_key=self.api_key, timeout=30.0, max_retries=3)
|
| 29 |
+
await self._test_connection()
|
| 30 |
+
logger.info("✅ OpenAI 클라이언트 초기화 완료")
|
| 31 |
+
|
| 32 |
+
async def _test_connection(self):
|
| 33 |
+
try: await self.client.chat.completions.create(model=self.default_model, messages=[{"role": "user", "content": "Hello"}], max_tokens=5)
|
| 34 |
+
except Exception as e: raise e
|
| 35 |
+
|
| 36 |
+
async def create_completion(self, messages: List[Dict[str, str]], **kwargs) -> str:
|
| 37 |
+
if not self.client: await self.initialize()
|
| 38 |
+
response = await self.client.chat.completions.create(
|
| 39 |
+
model=kwargs.get("model", self.default_model), messages=messages,
|
| 40 |
+
temperature=kwargs.get("temperature", 0.7), max_tokens=kwargs.get("max_tokens", 500)
|
| 41 |
+
)
|
| 42 |
+
return response.choices[0].message.content
|
| 43 |
+
|
| 44 |
+
async def rewrite_query_with_history(self, user_message: str, conversation_history: List[Dict]) -> str:
|
| 45 |
+
if not conversation_history: return user_message
|
| 46 |
+
history_str = "\n".join([f"[{msg['role']}] {msg['content']}" for msg in conversation_history])
|
| 47 |
+
prompt = f"""당신은 사용자의 대화 전체를 깊이 이해하여, 벡터 검색에 가장 적합한 검색 문장을 생성하는 '쿼리 재작성 전문가'입니다.
|
| 48 |
+
### 임무
|
| 49 |
+
주어진 '이전 대화 내용'과 '사용자의 마지막 메시지'를 종합하여, 사용자가 겪고 있는 문제의 핵심 상황과 감정이 모두 담긴, 단 하나의 완벽한 문장으로 재작성해야 합니다.
|
| 50 |
+
### 규칙
|
| 51 |
+
1. 반드시 사용자의 입장에서, 사용자가 겪는 문제 상황을 중심으로 서술해야 합니다.
|
| 52 |
+
2. 단순 키워드 나열은 절대 금지됩니다.
|
| 53 |
+
3. 오직 '재작성된 검색 쿼리:' 부분의 내용만 결과로 출력해야 합니다.
|
| 54 |
+
---
|
| 55 |
+
### 모범 답안 예시
|
| 56 |
+
[이전 대화 내용]
|
| 57 |
+
[assistant] 요즘 무슨 고민 있어?
|
| 58 |
+
[user] 제일 친한 친구가 요즘 나를 피하는 것 같아.
|
| 59 |
+
[사용자 마지막 메시지]
|
| 60 |
+
"방금도 단톡방에서 나만 빼고 자기들끼리만 얘기해."
|
| 61 |
+
[재작성된 검색 쿼리]
|
| 62 |
+
"가장 친한 친구가 다른 무리와 어울리며 단체 채팅방에서 나를 소외시켜 느끼는 따돌림과 서운함"
|
| 63 |
+
---
|
| 64 |
+
### 실제 과제
|
| 65 |
+
[이전 대화 내용]
|
| 66 |
+
{history_str}
|
| 67 |
+
[사용자 마지막 메시지]
|
| 68 |
+
"{user_message}"
|
| 69 |
+
[재작성된 검색 쿼리]
|
| 70 |
+
"""
|
| 71 |
+
rewritten_query = await self.create_completion(messages=[{"role": "user", "content": prompt}], temperature=0.0, max_tokens=200)
|
| 72 |
+
logger.info(f"대화형 쿼리 재작성: '{user_message}' -> '{rewritten_query.strip()}'")
|
| 73 |
+
return rewritten_query.strip()
|
| 74 |
+
|
| 75 |
+
async def analyze_emotion_and_context(self, text: str) -> dict:
|
| 76 |
+
emotion_list = [e.value for e in EmotionType]
|
| 77 |
+
relationship_list = [r.value for r in RelationshipType]
|
| 78 |
+
analysis_prompt = f"다음 청소년의 메시지에서 primary_emotion과 relationship_context를 추출해줘. 반드시 아래 목록의 한글 단어 중에서만 선택해서 JSON으로 응답해야 해.\n- primary_emotion: {emotion_list}\n- relationship_context: {relationship_list}\n\n메시지: \"{text}\""
|
| 79 |
+
try:
|
| 80 |
+
response_content = await self.create_completion(messages=[{"role": "user", "content": analysis_prompt}], temperature=0.0, max_tokens=200)
|
| 81 |
+
import json
|
| 82 |
+
return json.loads(response_content.strip())
|
| 83 |
+
except Exception:
|
| 84 |
+
return {"primary_emotion": EmotionType.ANXIETY.value, "relationship_context": RelationshipType.FRIEND.value}
|
| 85 |
+
|
| 86 |
+
def _apply_simple_conversions(self, text: str) -> str:
|
| 87 |
+
for old, new in self.conversion_map.items(): text = text.replace(old, new)
|
| 88 |
+
return text
|
| 89 |
+
|
| 90 |
+
async def verify_rag_relevance(self, user_message: str, retrieved_doc: str) -> bool:
|
| 91 |
+
prompt = f"사용자의 현재 메시지와 검색된 전문가 조언이 의미적으로 관련이 있는지 판단해줘. 반드시 'Yes' 또는 'No'로만 대답해.\n- 사용자 메시지: \"{user_message}\"\n- 검색된 조언: \"{retrieved_doc}\"\n\n관련이 있는가? (Yes/No):"
|
| 92 |
+
response = await self.create_completion(messages=[{"role": "user", "content": prompt}], temperature=0.0, max_tokens=5)
|
| 93 |
+
logger.info(f"RAG 검증 결과: {response.strip()}")
|
| 94 |
+
return "yes" in response.strip().lower()
|
| 95 |
+
|
| 96 |
+
async def adapt_expert_response(self, expert_response: str, user_situation: str, conversation_history: List[Dict]) -> Tuple[str, str, str, str]:
|
| 97 |
+
pre_adapted_response = self._apply_simple_conversions(expert_response)
|
| 98 |
+
messages = [{"role": "system", "content": self.teen_empathy_system_prompt}, *conversation_history, {"role": "user", "content": f"내 친구의 현재 상황은 '{user_situation}'이야. 내가 참고할 전문가 조언은 '{pre_adapted_response}'인데, 이 조언을 내 친구에게 말하듯 자연스럽고 따뜻한 반말로 바꿔줘."}]
|
| 99 |
+
final_prompt_for_debug = "\n".join([f"[{msg['role']}] {msg['content']}" for msg in messages])
|
| 100 |
+
final_response = await self.create_completion(messages=messages, temperature=0.5, max_tokens=400)
|
| 101 |
+
return expert_response, pre_adapted_response, final_response, final_prompt_for_debug
|
| 102 |
+
|
| 103 |
+
async def create_direct_response(self, user_message: str, conversation_history: List[Dict], inspirational_docs: Optional[List[str]] = None) -> Tuple[str, str]:
|
| 104 |
+
"""[최종 수정] '영감'을 위한 참고 자료(inspirational_docs)를 인자로 받아 프롬프트에 추가"""
|
| 105 |
+
|
| 106 |
+
messages = [
|
| 107 |
+
{"role": "system", "content": self.teen_empathy_system_prompt},
|
| 108 |
+
*conversation_history
|
| 109 |
+
]
|
| 110 |
+
|
| 111 |
+
inspiration_prompt = ""
|
| 112 |
+
if inspirational_docs:
|
| 113 |
+
inspiration_prompt = "\n\n### 참고 자료 (직접 언급하지 말고, 답변을 만들 때 영감을 얻는 용도로만 사용해)\n"
|
| 114 |
+
for doc in inspirational_docs:
|
| 115 |
+
inspiration_prompt += f"- {doc}\n"
|
| 116 |
+
|
| 117 |
+
final_user_prompt = f"""'마음이'의 페르소나(친한 친구, 반말)를 완벽하게 지키면서 다음 메시지에 공감하는 답변을 해줘.{inspiration_prompt}
|
| 118 |
+
|
| 119 |
+
"{user_message}"
|
| 120 |
+
"""
|
| 121 |
+
messages.append({"role": "user", "content": final_user_prompt})
|
| 122 |
+
|
| 123 |
+
final_response = await self.create_completion(messages=messages, temperature=0.7, max_tokens=300)
|
| 124 |
+
prompt_for_debug = "\n".join([f"[{msg['role']}] {msg['content']}" for msg in messages])
|
| 125 |
+
return final_response, prompt_for_debug
|
| 126 |
+
|
| 127 |
+
_openai_client_instance = None
|
| 128 |
+
async def get_openai_client() -> OpenAIClient:
|
| 129 |
+
global _openai_client_instance
|
| 130 |
+
if _openai_client_instance is None:
|
| 131 |
+
_openai_client_instance = OpenAIClient()
|
| 132 |
+
await _openai_client_instance.initialize()
|
| 133 |
+
return _openai_cㅎlient_instance
|
src/utils/__init__.py
ADDED
|
File without changes
|
static/index.html
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="ko">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>마음이 AI | 너의 마음을 듣는 시간</title>
|
| 7 |
+
<style>
|
| 8 |
+
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;700&display=swap');
|
| 9 |
+
:root { --accent-color: #8A2BE2; --bg-color: #f4f7f6; --font-color: #333; }
|
| 10 |
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
| 11 |
+
body { font-family: 'Noto Sans KR', sans-serif; background: var(--bg-color); display: flex; justify-content: center; align-items: center; min-height: 100vh; }
|
| 12 |
+
.chat-container { width: 100%; max-width: 700px; height: 95vh; max-height: 800px; background: #fff; border-radius: 20px; box-shadow: 0 8px 32px rgba(0,0,0,0.1); display: flex; flex-direction: column; }
|
| 13 |
+
.chat-header { background: var(--accent-color); color: white; padding: 20px; text-align: center; border-radius: 20px 20px 0 0; }
|
| 14 |
+
.chat-header h1 { font-size: 1.5rem; }
|
| 15 |
+
.chat-messages { flex: 1; padding: 20px; overflow-y: auto; }
|
| 16 |
+
.message { display: flex; margin-bottom: 20px; align-items: flex-end; }
|
| 17 |
+
.message.user { justify-content: flex-end; }
|
| 18 |
+
.avatar { width: 40px; height: 40px; border-radius: 50%; background: #eee; margin: 0 10px; font-size: 1.5rem; display: flex; justify-content: center; align-items: center; flex-shrink: 0;}
|
| 19 |
+
.message.user .avatar { background: #dcf8c6; }
|
| 20 |
+
.message.assistant .avatar { background: #e5e5ea; }
|
| 21 |
+
.message-bubble { max-width: 70%; padding: 12px 18px; border-radius: 18px; line-height: 1.6; }
|
| 22 |
+
.message.user .message-bubble { background: var(--accent-color); color: white; border-bottom-right-radius: 4px; }
|
| 23 |
+
.message.assistant .message-bubble { background: #e5e5ea; color: var(--font-color); border-bottom-left-radius: 4px; }
|
| 24 |
+
.chat-input-container { padding: 15px; border-top: 1px solid #eee; }
|
| 25 |
+
.chat-input-wrapper { display: flex; gap: 10px; }
|
| 26 |
+
.chat-input { flex: 1; border: 2px solid #ddd; border-radius: 25px; padding: 12px 20px; font-size: 1rem; }
|
| 27 |
+
.action-button { background: var(--accent-color); color: white; border: none; border-radius: 50%; width: 48px; height: 48px; cursor: pointer; font-size: 1.5rem; flex-shrink: 0; }
|
| 28 |
+
.action-button.debug { background: #ff6b6b; }
|
| 29 |
+
.typing-indicator .message-bubble { display: flex; align-items: center; gap: 5px; }
|
| 30 |
+
.typing-dot { width: 8px; height: 8px; background-color: #aaa; border-radius: 50%; animation: typing-pulse 1.4s infinite ease-in-out both; }
|
| 31 |
+
.typing-dot:nth-child(1) { animation-delay: -0.32s; }
|
| 32 |
+
.typing-dot:nth-child(2) { animation-delay: -0.16s; }
|
| 33 |
+
@keyframes typing-pulse { 0%, 80%, 100% { transform: scale(0); } 40% { transform: scale(1.0); } }
|
| 34 |
+
</style>
|
| 35 |
+
</head>
|
| 36 |
+
<body>
|
| 37 |
+
<div class="chat-container">
|
| 38 |
+
<div class="chat-header"><h1>💙 마음이 AI | 너의 마음을 듣는 시간</h1></div>
|
| 39 |
+
<div class="chat-messages" id="chatMessages">
|
| 40 |
+
<div class="message assistant"><div class="avatar">🤖</div><div class="message-bubble">안녕! 나는 너의 마음을 들어줄 친구 '마음이'야.</div></div>
|
| 41 |
+
<div class="message assistant" id="typingIndicator" style="display: none;">
|
| 42 |
+
<div class="avatar">🤖</div><div class="message-bubble"><div class="typing-dot"></div><div class="typing-dot"></div><div class="typing-dot"></div></div>
|
| 43 |
+
</div>
|
| 44 |
+
</div>
|
| 45 |
+
<div class="chat-input-container">
|
| 46 |
+
<div class="chat-input-wrapper">
|
| 47 |
+
<input type="text" class="chat-input" id="messageInput" placeholder="너의 이야기를 들려줘...">
|
| 48 |
+
<button class="action-button" id="sendButton" title="전송">➤</button>
|
| 49 |
+
<button class="action-button debug" id="openDebugButton" title="디버그 창 열기">🐞</button>
|
| 50 |
+
</div>
|
| 51 |
+
</div>
|
| 52 |
+
</div>
|
| 53 |
+
|
| 54 |
+
<script>
|
| 55 |
+
const chatMessages = document.getElementById('chatMessages');
|
| 56 |
+
const messageInput = document.getElementById('messageInput');
|
| 57 |
+
const sendButton = document.getElementById('sendButton');
|
| 58 |
+
const openDebugButton = document.getElementById('openDebugButton');
|
| 59 |
+
const typingIndicator = document.getElementById('typingIndicator');
|
| 60 |
+
|
| 61 |
+
let debugWindow = null;
|
| 62 |
+
let sessionId = `session_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
| 63 |
+
|
| 64 |
+
function displayMessage(text, sender) {
|
| 65 |
+
const avatar = sender === 'user' ? '👤' : '🤖';
|
| 66 |
+
const messageEl = document.createElement('div');
|
| 67 |
+
messageEl.className = `message ${sender}`;
|
| 68 |
+
messageEl.innerHTML = `<div class="avatar">${avatar}</div><div class="message-bubble">${text}</div>`;
|
| 69 |
+
chatMessages.appendChild(messageEl);
|
| 70 |
+
chatMessages.scrollTop = chatMessages.scrollHeight;
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
async function handleSendMessage() {
|
| 74 |
+
const message = messageInput.value.trim();
|
| 75 |
+
if (!message) return;
|
| 76 |
+
displayMessage(message, 'user');
|
| 77 |
+
messageInput.value = '';
|
| 78 |
+
|
| 79 |
+
// [최종 수정] '입력 중' 표시를 화면에 나타내기 전, 항상 맨 마지막 요소로 이동시킵니다.
|
| 80 |
+
chatMessages.appendChild(typingIndicator);
|
| 81 |
+
typingIndicator.style.display = 'flex';
|
| 82 |
+
chatMessages.scrollTop = chatMessages.scrollHeight;
|
| 83 |
+
|
| 84 |
+
const isDebugMode = (debugWindow && !debugWindow.closed);
|
| 85 |
+
const endpoint = isDebugMode ? '/api/v1/chat/teen-chat-debug' : '/api/v1/chat/teen-chat';
|
| 86 |
+
|
| 87 |
+
try {
|
| 88 |
+
const response = await fetch(endpoint, {
|
| 89 |
+
method: 'POST',
|
| 90 |
+
headers: { 'Content-Type': 'application/json', 'session-id': sessionId },
|
| 91 |
+
body: JSON.stringify({ message: message })
|
| 92 |
+
});
|
| 93 |
+
const data = await response.json();
|
| 94 |
+
displayMessage(data.response || "응답을 받지 못했습니다.", 'assistant');
|
| 95 |
+
if (isDebugMode) updateDebugWindow(data);
|
| 96 |
+
} catch (error) {
|
| 97 |
+
console.error('API 통신 오류:', error);
|
| 98 |
+
displayMessage('죄송해요, 통신 중 문제가 발생했어요.', 'bot');
|
| 99 |
+
} finally {
|
| 100 |
+
typingIndicator.style.display = 'none';
|
| 101 |
+
}
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
function openDebugWindow() {
|
| 105 |
+
if (debugWindow && !debugWindow.closed) { debugWindow.focus(); return; }
|
| 106 |
+
debugWindow = window.open('', 'Debug_Window', 'width=1400,height=900,scrollbars=yes,resizable=yes');
|
| 107 |
+
const debugHTML = `
|
| 108 |
+
<!DOCTYPE html><html lang="ko"><head><title>🔬 전체 과정 투명성 로그</title>
|
| 109 |
+
<style>
|
| 110 |
+
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", sans-serif; line-height: 1.6; padding: 20px; background: #f9f9fa; color: #333; }
|
| 111 |
+
.header { text-align: center; margin-bottom: 30px; }
|
| 112 |
+
.header h1 { color: #8A2BE2; }
|
| 113 |
+
.container { display: flex; gap: 20px; align-items: flex-start; }
|
| 114 |
+
.column { flex: 1; min-width: 0; }
|
| 115 |
+
.step { background: #fff; border: 1px solid #eef; border-radius: 12px; padding: 20px; box-shadow: 0 4px 12px rgba(0,0,0,0.05); margin-bottom: 15px; }
|
| 116 |
+
.step h3, .column h2 { font-size: 1.2em; color: #8A2BE2; display: flex; align-items: center; gap: 8px; margin-top: 0; }
|
| 117 |
+
.step-content { margin-top: 15px; }
|
| 118 |
+
pre { background: #f0f2f5; padding: 15px; border-radius: 8px; white-space: pre-wrap; word-wrap: break-word; font-family: 'SF Mono', Consolas, monospace; font-size: 0.9em; }
|
| 119 |
+
.diff-container { border: 1px solid #ddd; padding: 15px; border-radius: 8px; margin-top: 10px;}
|
| 120 |
+
.diff del { background-color: #ffebe9; color: #c00; text-decoration: none; }
|
| 121 |
+
.diff ins { background-color: #e6ffed; color: #22863a; text-decoration: none; }
|
| 122 |
+
.react-step { border-left: 3px solid #ccc; padding-left: 15px; margin-bottom: 10px; }
|
| 123 |
+
.react-step-thought { border-left-color: #f0ad4e; }
|
| 124 |
+
.react-step-action { border-left-color: #5cb85c; }
|
| 125 |
+
.react-step-observation { border-left-color: #5bc0de; }
|
| 126 |
+
</style></head><body>
|
| 127 |
+
<div class="header"><h1>🔬 전체 과정 투명성 로그</h1></div>
|
| 128 |
+
<div id="debug-content" class="container"></div>
|
| 129 |
+
</body></html>`;
|
| 130 |
+
debugWindow.document.write(debugHTML);
|
| 131 |
+
debugWindow.document.close();
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
function createDiffHtml(text1, text2) {
|
| 135 |
+
const a = text1.split(/(\s+)/); const b = text2.split(/(\s+)/);
|
| 136 |
+
const dp = Array(a.length + 1).fill(null).map(() => Array(b.length + 1).fill(0));
|
| 137 |
+
for (let i = a.length - 1; i >= 0; i--) {
|
| 138 |
+
for (let j = b.length - 1; j >= 0; j--) {
|
| 139 |
+
if (a[i] === b[j]) dp[i][j] = 1 + dp[i+1][j+1];
|
| 140 |
+
else dp[i][j] = Math.max(dp[i+1][j], dp[i][j+1]);
|
| 141 |
+
}
|
| 142 |
+
}
|
| 143 |
+
let i = 0, j = 0; let result = '';
|
| 144 |
+
while (i < a.length && j < b.length) {
|
| 145 |
+
if (a[i] === b[j]) { result += a[i]; i++; j++; }
|
| 146 |
+
else if (dp[i+1][j] >= dp[i][j+1]) { result += `<del>${a[i]}</del>`; i++; }
|
| 147 |
+
else { result += `<ins>${b[j]}</ins>`; j++; }
|
| 148 |
+
}
|
| 149 |
+
return `<div class="diff">${result}</div>`;
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
function updateDebugWindow(data) {
|
| 153 |
+
if (!debugWindow || !debugWindow.document) return;
|
| 154 |
+
const debugContentEl = debugWindow.document.getElementById('debug-content');
|
| 155 |
+
|
| 156 |
+
const escape = (str) => str ? str.toString().replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">") : '';
|
| 157 |
+
|
| 158 |
+
const reactStepsHtml = (data.react_steps || []).map(step => {
|
| 159 |
+
const content = escape(step.content || '');
|
| 160 |
+
return `<div class="react-step react-step-${step.step_type}"><strong>[${step.step_type.toUpperCase()}]</strong><pre>${content}</pre></div>`;
|
| 161 |
+
}).join('');
|
| 162 |
+
const reactColumnHtml = `<div class="column"><h2>🤔 ReAct 추론 과정</h2><div class="step">${reactStepsHtml}</div></div>`;
|
| 163 |
+
|
| 164 |
+
let detailHtml = '';
|
| 165 |
+
const info = data.debug_info || {};
|
| 166 |
+
for (const [stepKey, value] of Object.entries(info)) {
|
| 167 |
+
let contentHtml = '';
|
| 168 |
+
if (stepKey === 'step4_generation' && value.strategy === 'RAG-Adaptation') {
|
| 169 |
+
contentHtml = `<strong>전략:</strong> RAG 답변 적응<hr>
|
| 170 |
+
<h4>A. 원본 전문가 조언</h4> <pre>${escape(value.A_source_expert_advice)}</pre>
|
| 171 |
+
<h4>B. 1차 단어 변환</h4> ${createDiffHtml(value.A_source_expert_advice, value.B_rule_based_adaptation)}
|
| 172 |
+
<h4>C. 최종 GPT-4 프롬프트</h4> <pre>${escape(value.C_final_gpt4_prompt)}</pre>
|
| 173 |
+
<h4>D. 최종 생성 답변</h4> <pre style="background: var(--light-purple);">${escape(value.D_final_response)}</pre>`;
|
| 174 |
+
} else {
|
| 175 |
+
contentHtml = `<pre>${escape(JSON.stringify(value, null, 2))}</pre>`;
|
| 176 |
+
}
|
| 177 |
+
detailHtml += `<div class="step"><h3>${stepKey.replace('step', 'Step ').replace(/_/g, ' ')}</h3><div class="step-content">${contentHtml}</div></div>`;
|
| 178 |
+
}
|
| 179 |
+
const detailColumnHtml = `<div class="column"><h2>🔍 상세 데이터 흐름</h2>${detailHtml}</div>`;
|
| 180 |
+
|
| 181 |
+
debugContentEl.innerHTML = reactColumnHtml + detailColumnHtml;
|
| 182 |
+
}
|
| 183 |
+
|
| 184 |
+
sendButton.addEventListener('click', handleSendMessage);
|
| 185 |
+
openDebugButton.addEventListener('click', openDebugWindow);
|
| 186 |
+
messageInput.addEventListener('keypress', (e) => {
|
| 187 |
+
if (e.key === 'Enter') { e.preventDefault(); handleSendMessage(); }
|
| 188 |
+
});
|
| 189 |
+
</script>
|
| 190 |
+
</body>
|
| 191 |
+
</html>
|