youdie006 commited on
Commit
065853d
·
0 Parent(s):

fix: token

Browse files
.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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;") : '';
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>