Muyeong Kim Claude commited on
Commit
8211554
·
0 Parent(s):

초기 버전: 소방 복무관리 RAG 챗봇

Browse files

주요 기능:
- RAG 기반 질의응답 시스템 구현
- 다양한 문서 형식 지원 (PDF, Word, TXT, Excel)
- 한국어 임베딩 모델 최적화 (jhgan/ko-sbert-nli)
- FAISS 벡터 데이터베이스 활용한 고성능 검색
- Gradio 기반 웹 인터페이스
- 허깅페이스 Spaces 배포 최적화
- 샘플 복무관리 문서 포함

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

Files changed (8) hide show
  1. README.md +276 -0
  2. app.py +395 -0
  3. config.py +43 -0
  4. document_processor.py +291 -0
  5. gradio_interface.py +329 -0
  6. rag_chatbot.py +376 -0
  7. requirements.txt +30 -0
  8. vector_store.py +266 -0
README.md ADDED
@@ -0,0 +1,276 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 🚒 소방 복무관리 RAG 챗봇
2
+
3
+ 소방공무원 복무관리 규정 및 절차에 대한 질문에 답변해 드리는 RAG(Retrieval-Augmented Generation) 기반 챗봇입니다.
4
+
5
+ ## 📋 프로젝트 개요
6
+
7
+ - **목적**: 소방업무 복무관리 관련 문서들을 활용한 지능형 질의응답 시스템
8
+ - **기술**: RAG, Sentence-BERT, FAISS, Gradio, Hugging Face
9
+ - **문서 유형**: PDF, Word, TXT, Excel, CSV
10
+ - **특징**: 한국어 최적화, 실시간 검색, 웹 인터페이스
11
+
12
+ ## 🎯 주요 기능
13
+
14
+ ### ✅ RAG 검색 시스템
15
+ - 문서 내용 자동 청킹
16
+ - 의미 기반 검색
17
+ - 유사도 측정
18
+ - 다중 문서 검색
19
+
20
+ ### ✅ 인터페이스
21
+ - Gradio 기반 웹 채팅
22
+ - 실시간 응답
23
+ - 출처 정보 표시
24
+ - 신뢰도 점수 제공
25
+
26
+ ### ✅ 문서 처리
27
+ - 다양한 파일 형식 지원 (PDF, DOCX, TXT, XLSX, CSV)
28
+ - 한국어 텍스트 전처리
29
+ - 소방 용어 최적화
30
+ - 동적 문서 추가
31
+
32
+ ## 🏗️ 시스템 아키텍처
33
+
34
+ ```
35
+ 📂 문서 폴더
36
+
37
+ 📄 문서 처리기 (DocumentProcessor)
38
+
39
+ 🧠 임베딩 모델 (Sentence-BERT)
40
+
41
+ 🔍 벡터 DB (FAISS)
42
+
43
+ 💬 RAG 챗봇 (Chatbot)
44
+
45
+ 🌐 웹 인터페이스 (Gradio)
46
+ ```
47
+
48
+ ## 🚀 시작하기
49
+
50
+ ### 1. 환경 설정
51
+
52
+ ```bash
53
+ # 1. 저장소 복제
54
+ git clone <repository-url>
55
+ cd 119_chatbot
56
+
57
+ # 2. Python 가상환경 생성
58
+ python -m venv venv
59
+
60
+ # 3. 가상환경 활성화
61
+ # Windows
62
+ venv\Scripts\activate
63
+ # Linux/Mac
64
+ source venv/bin/activate
65
+
66
+ # 4. 라이브러리 설치
67
+ pip install -r requirements.txt
68
+ ```
69
+
70
+ ### 2. 문서 준비
71
+
72
+ ```bash
73
+ # documents 폴더 생성
74
+ mkdir documents
75
+
76
+ # 복무관리 관련 문서 추가
77
+ # 지원 형식: .pdf, .docx, .txt, .xlsx, .csv
78
+ ```
79
+
80
+ ### 3. 로컬 실행
81
+
82
+ ```bash
83
+ # 방법 1: 간단한 테스트
84
+ python document_processor.py
85
+
86
+ # 방법 2: 챗봇 테스트
87
+ python rag_chatbot.py
88
+
89
+ # 방법 3: 웹 인터페이스 실행
90
+ python gradio_interface.py
91
+
92
+ # 방법 4: 허깅페이스 배포용 실행
93
+ python app.py
94
+ ```
95
+
96
+ ## 📁 프로젝트 구조
97
+
98
+ ```
99
+ 119_chatbot/
100
+ ├── app.py # 허깅페이스 배포용 메인 파일
101
+ ├── config.py # 시스템 설정
102
+ ├── requirements.txt # 라이브러리 목록
103
+ ├── README.md # 프로젝트 설명서
104
+ ├── document_processor.py # 문서 처리 모듈
105
+ ├── vector_store.py # 벡터 데이터베이스
106
+ ├── rag_chatbot.py # RAG 챗봇 핵심 로직
107
+ ├── gradio_interface.py # Gradio 웹 인터페이스
108
+ ├── documents/ # 문서 폴더
109
+ │ ├── 복무관리규정.txt
110
+ │ ├── 인사평가규정.txt
111
+ │ └── ...
112
+ └── faiss_index/ # 벡터 인덱스 캐시
113
+ ```
114
+
115
+ ## ⚙️ 설정 옵션
116
+
117
+ ### config.py 주요 설정
118
+
119
+ ```python
120
+ # 모델 설정
121
+ EMBEDDING_MODEL = "jhgan/ko-sbert-nli" # 한국어 임베딩 모델
122
+ LLM_MODEL = "beomi/Llama-3-Open-Ko-8B" # 한국어 LLM
123
+
124
+ # RAG 파라미터
125
+ CHUNK_SIZE = 500 # 문서 청크 크기
126
+ CHUNK_OVERLAP = 50 # 청크 중복 크기
127
+ MAX_RETRIEVE_DOCS = 3 # 검색할 문서 수
128
+
129
+ # 경로 설정
130
+ DOCS_FOLDER = "documents" # 문서 폴더
131
+ VECTOR_DB_PATH = "faiss_index" # 벡터 DB 경로
132
+ ```
133
+
134
+ ## 🧪 사용 예시
135
+
136
+ ### 기본 질문
137
+
138
+ ```
139
+ Q: 연차휴가 사용 방법을 알려주세요
140
+ A: 연차휴가는 1년간 정상 근무한 자에게 15일을 부여합니다.
141
+ 사용 시 3일 전까지 신청서를 제출하고 부서장의 승인을 받아야 합니다.
142
+
143
+ 📚 출처: 복무관리규정.txt (신뢰도: 92%)
144
+ ```
145
+
146
+ ### 고급 기능
147
+
148
+ ```python
149
+ # 직접 코드로 사용
150
+ from rag_chatbot import RAGChatbot
151
+
152
+ # 챗봇 초기화
153
+ chatbot = RAGChatbot()
154
+ chatbot.initialize()
155
+
156
+ # 질문하고 답변 받기
157
+ response = chatbot.generate_answer("연차휴가 절차를 알려주세요")
158
+ print(response.answer)
159
+ ```
160
+
161
+ ## 🌐 배포 방법
162
+
163
+ ### 1. 허깅페이스 Spaces
164
+
165
+ ```bash
166
+ # 1. 허깅페이스 계정으로 로그인
167
+ huggingface-cli login
168
+
169
+ # 2. 새로운 Space 생성
170
+ # - Space 이름: fire-service-rag-chatbot
171
+ # - SDK: Gradio
172
+ # - Hardware: CPU Basic (무료)
173
+
174
+ # 3. 파일 업로드
175
+ git add .
176
+ git commit -m "초기 버전 배포"
177
+ git push origin main
178
+ ```
179
+
180
+ ### 2. 로컬 서버
181
+
182
+ ```bash
183
+ # 포트 7860으로 실행
184
+ python app.py --port 7860
185
+
186
+ # 공유 링크 생성
187
+ python app.py --share
188
+ ```
189
+
190
+ ## 📊 성능 최적화
191
+
192
+ ### 🔍 검색 성능 향상
193
+ - **Chunk 크기**: 500-1000 토큰으로 최적화
194
+ - **검색 문서 수**: 3-5개로 설정하여 속도와 정확도 균형
195
+ - **임베딩 모델**: 한국어 전용 모델 사용
196
+
197
+ ### 💾 메모리 관리
198
+ - **캐싱**: 벡터 인덱스 로컬 저장
199
+ - **동적 로드**: 필요 시에만 LLM 모델 로드
200
+ - **청크 최적화**: 너무 긴 문서 분리
201
+
202
+ ### ⚡ 응답 속도
203
+ - **템플릿 모드**: LLM 없이 빠른 응답
204
+ - **캐싱**: 자�� 묻는 질문 응답 저장
205
+ - **비동기 처리**: 대용량 문서 처리 시 배치 작업
206
+
207
+ ## 🔧 개발 가이드
208
+
209
+ ### 새로운 문서 추가
210
+
211
+ ```python
212
+ from document_processor import DocumentProcessor
213
+
214
+ # 문서 처리기 생성
215
+ processor = DocumentProcessor()
216
+
217
+ # 새 문서 로드
218
+ new_docs = processor.load_documents_from_folder("new_documents")
219
+
220
+ # 기존 문서에 추가
221
+ from vector_store import VectorStore
222
+ vector_store = VectorStore()
223
+ vector_store.add_documents(new_docs)
224
+ ```
225
+
226
+ ### 커스텀 프롬프트
227
+
228
+ ```python
229
+ # config.py에서 시스템 프롬프트 수정
230
+ SYSTEM_PROMPT = """
231
+ 당신은 소방 복무관리 전문가입니다...
232
+ [원하는 프롬프트 내용]
233
+ """
234
+ ```
235
+
236
+ ## 🚨 주의사항
237
+
238
+ ### 보안
239
+ - 민감한 개인정보가 포함된 문서는 업로드하지 마세요
240
+ - 법적 효력이 있는 최신 규정만 사용하세요
241
+ - 정기적으로 문서를 업데이트해야 합니다
242
+
243
+ ### 성능
244
+ - 대용량 문서 처리 시 시간이 오래 걸릴 수 있습니다
245
+ - LLM 모델 로드 시 충분한 메모리가 필요합니다
246
+ - 무료 플랜의 경우 리소스 제한이 있을 수 있습니다
247
+
248
+ ### 정확성
249
+ - 생성된 답변은 반드시 실제 규정과 교차 확인하세요
250
+ - 중요한 결정 시 반드시 담당자와 상담하세요
251
+ - 챗봇 답변을 법적 효력으로 사용하지 마세요
252
+
253
+ ## 🤝 기여 방법
254
+
255
+ 1. 이슈 등록: 버그나 개선사항 등록
256
+ 2. 코드 제출: Pull Request를 통한 기여
257
+ 3. 문서 개선: README나 코드 주석 개선
258
+ 4. 테스트: 다양한 문서로 테스트 및 피드백
259
+
260
+ ## 📞 문의
261
+
262
+ - 개발자: Claude (AI Assistant)
263
+ - 이메일: [개발자 이메일]
264
+ - 라이선스: MIT License
265
+
266
+ ## 🙏 감사
267
+
268
+ - **Hugging Face**: 오픈소스 모델 및 플랫폼 제공
269
+ - **LangChain**: RAG 프레임워크 제공
270
+ - **Sentence-Transformers**: 한국어 임베딩 모델 제공
271
+ - **FAISS**: 고성능 벡터 검색 라이브러리 제공
272
+ - **Gradio**: 간편한 웹 인터페이스 제공
273
+
274
+ ---
275
+
276
+ **⚠️ 본 챗봇은 보조 도구입니다. 중요한 업무 결정 시 반드시 관련 규정 원본과 담당자의 확인을 받으세요!**
app.py ADDED
@@ -0,0 +1,395 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ 허깅페이스 Spaces 배포용 메인 파일
4
+ 소방 복무관리 RAG 챗봇
5
+ """
6
+
7
+ import os
8
+ import sys
9
+ import argparse
10
+ import gradio as gr
11
+ import pandas as pd
12
+ from pathlib import Path
13
+ from typing import List, Dict, Tuple
14
+
15
+ # 현재 디렉토리를 Python 경로에 추가
16
+ current_dir = os.path.dirname(os.path.abspath(__file__))
17
+ sys.path.append(current_dir)
18
+
19
+ # 모듈 임포트
20
+ from config import Config
21
+ from rag_chatbot import RAGChatbot
22
+ from document_processor import DocumentProcessor
23
+
24
+ class HuggingFaceApp:
25
+ """허깅페이스 Spaces 배포용 앱 클래스"""
26
+
27
+ def __init__(self):
28
+ self.chatbot = RAGChatbot()
29
+ self.is_initialized = False
30
+
31
+ # 예시 질문 (허깅페이스 환경 최적화)
32
+ self.example_questions = [
33
+ "연차휴가 사용 방법을 알려주세요",
34
+ "정규근무시간은 어떻게 되나요?",
35
+ "당직근무 절차가 궁금합니다",
36
+ "인사평가는 언제 진행되나요?",
37
+ "파견근무 신청 방법",
38
+ "복무규정 위반 시 처리"
39
+ ]
40
+
41
+ # 앱 초기화
42
+ self._initialize_app()
43
+
44
+ def _initialize_app(self):
45
+ """앱 초기화"""
46
+ try:
47
+ print("🚀 소방 복무관리 RAG 챗봇 시작 중...")
48
+
49
+ # 문서 폴더 확인 및 샘플 데이터 생성
50
+ self._ensure_documents()
51
+
52
+ # 챗봇 초기화
53
+ success = self.chatbot.initialize()
54
+ if success:
55
+ self.is_initialized = True
56
+ print("✅ 챗봇 초기화 완료")
57
+ else:
58
+ print("⚠️ 챗봇 초기화 실패 - 템플릿 모드로 동작")
59
+
60
+ except Exception as e:
61
+ print(f"❌ 초기화 오류: {str(e)}")
62
+ # 오류가 있어도 앱은 계속 실행
63
+
64
+ def _ensure_documents(self):
65
+ """문서 폴더 및 샘플 데이터 확인"""
66
+ docs_folder = Path("documents")
67
+ docs_folder.mkdir(exist_ok=True)
68
+
69
+ # 샘플 문서가 없으면 생성
70
+ sample_files = list(docs_folder.glob("*.txt"))
71
+ if not sample_files:
72
+ print("📝 샘플 문서 생성 중...")
73
+ self._create_sample_documents()
74
+
75
+ def _create_sample_documents(self):
76
+ """샘플 복무관리 문서 생성"""
77
+ sample_docs = [
78
+ {
79
+ "filename": "복무관리규정.txt",
80
+ "content": """소방공무원 복무관리 규정
81
+
82
+ 제1장 총칙
83
+ 제1조 (목적)
84
+ 이 규정은 소방공무원의 복무에 관한 기본사항을 규정하여 직무수행의 효율성을 높이고 조직의 발전에 기여함을 목적으로 한다.
85
+
86
+ 제2조 (근무시간)
87
+ 1. 정규근무시간은 09:00부터 18:00까지로 한다.
88
+ 2. 점심시간은 12:00부터 13:00까지로 한다.
89
+ 3. 토요일, 일요일 및 법정공휴일은 휴무일로 한다.
90
+
91
+ 제3조 (연차휴가)
92
+ 1. 연차휴가는 1년간 정상 근무한 자에게 15일을 부여한다.
93
+ 2. 연차휴가 사용 시 3일 전까지 신청서를 제출해야 한다.
94
+ 3. 부서장의 승인을 받아 사용하며, 긴급한 경우에는 사후 승인도 가능하다.
95
+
96
+ 제4조 (당직근무)
97
+ 1. 당직근무는 정규근무시간 외에 수행하는 근무를 말한다.
98
+ 2. 당직자는 비상상황에 대비한 통신장비를 항시 점검해야 한다.
99
+ 3. 당직 중에는 음주를 엄금하며, 직무 수행에 지장이 없는 행위만 가능하다.
100
+ """
101
+ },
102
+ {
103
+ "filename": "인사평가규정.txt",
104
+ "content": """소방공무원 인사평가 규정
105
+
106
+ 제1장 평가의 기본원칙
107
+ 제1조 (평가목적)
108
+ 소방공무원의 직무수행 능력과 성과를 객관적으로 평가하여 능력위주의 인사관리를 정립하고 공정한 보상 및 승진의 기초 자료로 활용한다.
109
+
110
+ 제2조 (평가주기)
111
+ 1. 정기평가는 연 1회 실시하며, 평가기간은 매년 1월 1일부터 12월 31일까지로 한다.
112
+ 2. 수시평가는 특별한 사유가 있을 경우 실시할 수 있다.
113
+
114
+ 제3조 (평가항목)
115
+ 1. 직무수행 능력 (40점)
116
+ 2. 업무 성과 (30점)
117
+ 3. 근무 태도 (20점)
118
+ 4. 협업 능력 (10점)
119
+
120
+ 제4조 (평가등급)
121
+ - 수 (90점 이상)
122
+ - 우 (80점 이상 90점 미만)
123
+ - 양 (70점 이상 80점 미만)
124
+ - 가 (60점 이상 70점 미만)
125
+ - 미 (60점 미만)
126
+ """
127
+ },
128
+ {
129
+ "filename": "교육훈련.txt",
130
+ "content": """소방공무원 교육훈련 안내
131
+
132
+ 제1조 (교육목적)
133
+ 소방공무원의 전문성 향상과 직무능력 개발을 위한 체계적인 교육훈련을 실시한다.
134
+
135
+ 제2조 (필수교육)
136
+ 1. 신임교육: 신규 임용자 대상 2주간 집체교육
137
+ 2. 직무연수: 매년 1회, 직무별 전문교육
138
+ 3. 안전교육: 분기별 1회, 안전사고 예방 교육
139
+
140
+ 제3조 (선택교육)
141
+ 1. 외국어 교육
142
+ 2. 정보통신 기술 교육
143
+ 3. 리더십 교육
144
+ 4. 전문 자격증 취득 지원 교육
145
+
146
+ 제4조 (교육신청)
147
+ 1. 교육 희망자는 소속 기관을 통해 신청한다.
148
+ 2. 신청 시기는 교육 시작일 1개월 전까지이다.
149
+ 3. 업무에 지장이 없는 경우 우선 선발한다.
150
+ """
151
+ }
152
+ ]
153
+
154
+ for doc in sample_docs:
155
+ file_path = Path("documents") / doc["filename"]
156
+ try:
157
+ with open(file_path, 'w', encoding='utf-8') as f:
158
+ f.write(doc["content"])
159
+ print(f"✅ {doc['filename']} 생성 완료")
160
+ except Exception as e:
161
+ print(f"❌ {doc['filename']} 생성 실패: {str(e)}")
162
+
163
+ def format_response(self, response) -> str:
164
+ """응답을 Gradio 형식으로 변환"""
165
+ try:
166
+ # 메시지 형식으로 변환
167
+ answer = response.answer
168
+
169
+ # 신뢰도 표시
170
+ confidence = getattr(response, 'confidence', 0.0)
171
+ if confidence >= 0.8:
172
+ confidence_emoji = "🟢"
173
+ elif confidence >= 0.5:
174
+ confidence_emoji = "🟡"
175
+ else:
176
+ confidence_emoji = "🔴"
177
+
178
+ # 응답 시간
179
+ response_time = getattr(response, 'response_time', 0.0)
180
+
181
+ # 출처 정보
182
+ sources = getattr(response, 'sources', [])
183
+ source_text = ""
184
+ if sources:
185
+ source_text = "\n\n📚 **참고자료:**\n"
186
+ for i, source in enumerate(sources[:3], 1): # 최대 3개만 표시
187
+ source_name = source.get('source', '알 수 없음')
188
+ source_text += f"{i}. {source_name}\n"
189
+
190
+ # 전체 응답
191
+ full_response = f"""{answer}
192
+
193
+ ---
194
+ {confidence_emoji} 신뢰도: {confidence:.1%}
195
+ ⏱️ 응답시간: {response_time:.2f}초
196
+ 📄 참고문서: {len(sources)}개{source_text}"""
197
+
198
+ return full_response
199
+
200
+ except Exception as e:
201
+ return f"응답 형식 변환 중 오류 발생: {str(e)}"
202
+
203
+ def chat_function(self, message: str, history: List[List[str]]) -> List[List[str]]:
204
+ """채팅 함수"""
205
+ if not message.strip():
206
+ return history
207
+
208
+ try:
209
+ # 챗봇 응답 생성
210
+ if self.is_initialized:
211
+ response = self.chatbot.generate_answer(message, use_llm=False)
212
+ answer = self.format_response(response)
213
+ else:
214
+ answer = "죄송합니다. 챗봇이 초기화되지 않았습니다. 페이지를 새로고침해주세요."
215
+
216
+ # 히스토리에 추가
217
+ history.append([message, answer])
218
+
219
+ except Exception as e:
220
+ error_msg = f"답변 생성 중 오류 발생: {str(e)}\n\n관리자에게 문의해주세요."
221
+ history.append([message, error_msg])
222
+
223
+ return history
224
+
225
+ def create_demo(self):
226
+ """Gradio 데모 생성"""
227
+ # 커스텀 CSS
228
+ custom_css = """
229
+ .gradio-container {
230
+ max-width: 900px !important;
231
+ margin: auto !important;
232
+ }
233
+ .message.user {
234
+ background-color: #e3f2fd;
235
+ border-radius: 15px 15px 0 15px;
236
+ }
237
+ .message.assistant {
238
+ background-color: #f1f8e9;
239
+ border-radius: 15px 15px 15px 0;
240
+ }
241
+ """
242
+
243
+ with gr.Blocks(
244
+ title="소방 복무관리 RAG 챗봇",
245
+ theme=gr.themes.Soft(),
246
+ css=custom_css
247
+ ) as demo:
248
+
249
+ gr.Markdown("""
250
+ # 🚒 소방 복무관리 RAG 챗봇
251
+
252
+ 소방공무원 복무관리 규정, 인사평가, 교육훈련 등 업무 관련 질문에 답변해 드립니다.
253
+
254
+ 💡 **사용 방법**: 아래에 복무관리 관련 질문을 입력하고 Enter 키를 누르세요.
255
+ """)
256
+
257
+ # 상태 표시
258
+ with gr.Row():
259
+ status_text = gr.HTML(
260
+ "✅ **챗봇 준비 완료** - 복무관리 관련 질문을 입력해주세요"
261
+ )
262
+
263
+ # 채팅 인터페이스
264
+ chatbot = gr.Chatbot(
265
+ height=500,
266
+ show_copy_button=True,
267
+ bubble_full_width=False,
268
+ placeholder="안녕하세요! 소방 복무관리에 대해 무엇이 궁금하신가요?",
269
+ avatar_images=["👤", "🤖"]
270
+ )
271
+
272
+ # 입력 영역
273
+ with gr.Row():
274
+ msg = gr.Textbox(
275
+ placeholder="복무관리 관련 질문을 입력해주세요 (예: 연차휴가 사용 방법)",
276
+ container=False,
277
+ scale=7
278
+ )
279
+ submit_btn = gr.Button("전송", scale=1, variant="primary")
280
+ clear_btn = gr.Button("초기화", scale=1)
281
+
282
+ # 예시 질문
283
+ gr.Markdown("### 💡 예시 질문 (클릭하면 자동 입력됩니다)")
284
+
285
+ with gr.Row():
286
+ with gr.Column():
287
+ gr.Button("연차휴가 사용 방법", size="sm").click(
288
+ fn=lambda: "연차휴가 사용 방법을 알려주세요",
289
+ outputs=msg
290
+ )
291
+ gr.Button("정규근무시간", size="sm").click(
292
+ fn=lambda: "정규근무시간은 어떻게 되나요?",
293
+ outputs=msg
294
+ )
295
+ gr.Button("당직근무 절차", size="sm").click(
296
+ fn=lambda: "당직근무 절차가 궁금합니다",
297
+ outputs=msg
298
+ )
299
+ with gr.Column():
300
+ gr.Button("인사평가 기준", size="sm").click(
301
+ fn=lambda: "인사평가는 어떤 기준으로 이루어지나요?",
302
+ outputs=msg
303
+ )
304
+ gr.Button("교육훈련 안내", size="sm").click(
305
+ fn=lambda: "교육훈련 종류와 신청 방법을 알려주세요",
306
+ outputs=msg
307
+ )
308
+ gr.Button("파견근무 신청", size="sm").click(
309
+ fn=lambda: "파견근무 신청 방법을 알려주세요",
310
+ outputs=msg
311
+ )
312
+
313
+ # 정보 섹션
314
+ with gr.Accordion("📊 시스템 정보", open=False):
315
+ if self.is_initialized:
316
+ stats = self.chatbot.get_stats()
317
+ vector_stats = stats.get("vector_store", {})
318
+ gr.Markdown(f"""
319
+ - **문서 수**: {vector_stats.get('total_documents', 0)}개
320
+ - **임베딩 모델**: {vector_stats.get('embedding_model', 'N/A')}
321
+ - **응답 모드**: 템플릿 기반
322
+ - **최대 검색 문서**: {Config.MAX_RETRIEVE_DOCS}개
323
+ """)
324
+ else:
325
+ gr.Markdown("⚠️ 챗봇이 초기화되지 않았습니다.")
326
+
327
+ # 이벤트 핸들러
328
+ def user_input(user_message, history):
329
+ """사용자 입력 처리"""
330
+ return "", history + [[user_message, None]]
331
+
332
+ def bot_response(history):
333
+ """봇 응답 처리"""
334
+ if history and history[-1][1] is None:
335
+ user_message = history[-1][0]
336
+ bot_message = self.chat_function(user_message, history[:-1])
337
+ if bot_message:
338
+ history[-1][1] = bot_message[-1][1] # 마지막 응답만 가져오기
339
+ else:
340
+ history[-1][1] = "죄송합니다. 응답을 생성할 수 없습니다."
341
+ return history
342
+
343
+ # 메시지 전송 이벤트
344
+ msg.submit(
345
+ user_input,
346
+ [msg, chatbot],
347
+ [msg, chatbot],
348
+ queue=False
349
+ ).then(
350
+ bot_response,
351
+ chatbot,
352
+ chatbot
353
+ )
354
+
355
+ submit_btn.click(
356
+ user_input,
357
+ [msg, chatbot],
358
+ [msg, chatbot],
359
+ queue=False
360
+ ).then(
361
+ bot_response,
362
+ chatbot,
363
+ chatbot
364
+ )
365
+
366
+ clear_btn.click(
367
+ lambda: ([], ""),
368
+ outputs=[chatbot, msg]
369
+ )
370
+
371
+ return demo
372
+
373
+ def main():
374
+ """메인 실행 함수"""
375
+ parser = argparse.ArgumentParser(description="소방 복무관리 RAG 챗봇")
376
+ parser.add_argument("--share", action="store_true", help="공유 링크 생성")
377
+ parser.add_argument("--port", type=int, default=7860, help="서버 포트")
378
+ args = parser.parse_args()
379
+
380
+ # 앱 생성
381
+ app = HuggingFaceApp()
382
+ demo = app.create_demo()
383
+
384
+ # 실행
385
+ print("🚀 허깅페이스 Spaces 앱 시작 중...")
386
+ demo.launch(
387
+ share=args.share,
388
+ server_port=args.port,
389
+ server_name="0.0.0.0",
390
+ show_error=True,
391
+ show_tips=False
392
+ )
393
+
394
+ if __name__ == "__main__":
395
+ main()
config.py ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 복무관리 RAG 챗봇 설정
2
+ import os
3
+ from dotenv import load_dotenv
4
+
5
+ # 환경 변수 로드
6
+ load_dotenv()
7
+
8
+ class Config:
9
+ # 모델 설정
10
+ EMBEDDING_MODEL = "jhgan/ko-sbert-nli" # 한국어 임베딩 모델
11
+ LLM_MODEL = "beomi/Llama-3-Open-Ko-8B" # 한국어 LLM
12
+
13
+ # RAG 설정
14
+ CHUNK_SIZE = 500
15
+ CHUNK_OVERLAP = 50
16
+ MAX_RETRIEVE_DOCS = 3
17
+
18
+ # 벡터 DB 설정
19
+ VECTOR_DB_PATH = "faiss_index"
20
+
21
+ # 문서 폴더 설정
22
+ DOCS_FOLDER = "documents"
23
+
24
+ # 웹 인터페이스 설정
25
+ APP_NAME = "소방 복무관리 RAG 챗봇"
26
+ APP_DESCRIPTION = "소방업무 복무관리 규정 및 절차에 대한 질문에 답변해 드립니다."
27
+
28
+ # 토큰 설정 (필요시)
29
+ HUGGINGFACE_TOKEN = os.getenv("HUGGINGFACE_TOKEN", "")
30
+
31
+ # 시스템 프롬프트
32
+ SYSTEM_PROMPT = """
33
+ 당신은 소방업무 복무관리 전문가입니다. 복무관리 규정, 인사운영, 근무절차 등에 대한 질문에 정확하고 친절하게 답변해 주세요.
34
+
35
+ 답변 시 다음 사항을 준수해 주세요:
36
+ 1. 관련 규정 조문이나 근거를 명확히 제시
37
+ 2. 절차가 있는 경우 단계별로 설명
38
+ 3. 필요한 서류나 양식을 안내
39
+ 4. 주의사항이나 중요한 사항은 강조 표시
40
+ 5. 모든 답변은 한국어로 제공
41
+
42
+ 사용자의 질문에 최대한 상세하고 정확한 정보를 제공하세요.
43
+ """
document_processor.py ADDED
@@ -0,0 +1,291 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import re
3
+ from typing import List, Dict
4
+ from pathlib import Path
5
+ import PyMuPDF
6
+ import docx
7
+ import pandas as pd
8
+ from langchain.text_splitter import RecursiveCharacterTextSplitter
9
+ from langchain.schema import Document
10
+
11
+ class DocumentProcessor:
12
+ """복무관리 문서 처리 클래스"""
13
+
14
+ def __init__(self, chunk_size: int = 500, chunk_overlap: int = 50):
15
+ self.chunk_size = chunk_size
16
+ self.chunk_overlap = chunk_overlap
17
+ self.text_splitter = RecursiveCharacterTextSplitter(
18
+ chunk_size=chunk_size,
19
+ chunk_overlap=chunk_overlap,
20
+ separators=["\n\n", "\n", " ", ""]
21
+ )
22
+
23
+ def load_documents_from_folder(self, folder_path: str) -> List[Document]:
24
+ """폴더에서 모든 문서 로드"""
25
+ documents = []
26
+ folder = Path(folder_path)
27
+
28
+ if not folder.exists():
29
+ print(f"⚠️ 폴더가 존재하지 않습니다: {folder_path}")
30
+ return documents
31
+
32
+ # 지원하는 파일 형식
33
+ supported_extensions = ['.pdf', '.docx', '.txt', '.xlsx', '.csv']
34
+
35
+ for file_path in folder.rglob('*'):
36
+ if file_path.suffix.lower() in supported_extensions:
37
+ try:
38
+ print(f"📄 문서 로드: {file_path.name}")
39
+ docs = self.load_single_document(str(file_path))
40
+ documents.extend(docs)
41
+ except Exception as e:
42
+ print(f"❌ 문서 로드 실패 ({file_path.name}): {str(e)}")
43
+
44
+ return documents
45
+
46
+ def load_single_document(self, file_path: str) -> List[Document]:
47
+ """단일 문서 로드"""
48
+ file_ext = Path(file_path).suffix.lower()
49
+
50
+ if file_ext == '.pdf':
51
+ return self._load_pdf(file_path)
52
+ elif file_ext == '.docx':
53
+ return self._load_docx(file_path)
54
+ elif file_ext == '.txt':
55
+ return self._load_txt(file_path)
56
+ elif file_ext in ['.xlsx', '.csv']:
57
+ return self._load_table(file_path)
58
+ else:
59
+ raise ValueError(f"지원하지 않는 파일 형식: {file_ext}")
60
+
61
+ def _load_pdf(self, file_path: str) -> List[Document]:
62
+ """PDF 파일 로드"""
63
+ documents = []
64
+
65
+ try:
66
+ with PyMuPDF.open(file_path) as doc:
67
+ full_text = ""
68
+
69
+ for page_num in range(len(doc)):
70
+ page = doc[page_num]
71
+ page_text = page.get_text()
72
+
73
+ # 페이지 정제
74
+ page_text = self._clean_text(page_text)
75
+
76
+ if page_text.strip():
77
+ full_text += f"\n\n--- 페이지 {page_num + 1} ---\n\n{page_text}"
78
+
79
+ if full_text.strip():
80
+ chunks = self.text_splitter.split_text(full_text)
81
+
82
+ for i, chunk in enumerate(chunks):
83
+ metadata = {
84
+ "source": Path(file_path).name,
85
+ "page": "multiple",
86
+ "chunk_id": i,
87
+ "file_type": "pdf"
88
+ }
89
+
90
+ documents.append(Document(page_content=chunk, metadata=metadata))
91
+
92
+ except Exception as e:
93
+ print(f"PDF 로드 중 오류: {str(e)}")
94
+ raise
95
+
96
+ return documents
97
+
98
+ def _load_docx(self, file_path: str) -> List[Document]:
99
+ """Word 문서 로드"""
100
+ documents = []
101
+
102
+ try:
103
+ doc = docx.Document(file_path)
104
+ paragraphs = []
105
+
106
+ for para in doc.paragraphs:
107
+ if para.text.strip():
108
+ paragraphs.append(para.text)
109
+
110
+ full_text = "\n\n".join(paragraphs)
111
+
112
+ if full_text.strip():
113
+ chunks = self.text_splitter.split_text(full_text)
114
+
115
+ for i, chunk in enumerate(chunks):
116
+ metadata = {
117
+ "source": Path(file_path).name,
118
+ "chunk_id": i,
119
+ "file_type": "docx"
120
+ }
121
+
122
+ documents.append(Document(page_content=chunk, metadata=metadata))
123
+
124
+ except Exception as e:
125
+ print(f"DOCX 로드 중 오류: {str(e)}")
126
+ raise
127
+
128
+ return documents
129
+
130
+ def _load_txt(self, file_path: str) -> List[Document]:
131
+ """텍스트 파일 로드"""
132
+ documents = []
133
+
134
+ try:
135
+ with open(file_path, 'r', encoding='utf-8') as f:
136
+ text = f.read()
137
+
138
+ text = self._clean_text(text)
139
+
140
+ if text.strip():
141
+ chunks = self.text_splitter.split_text(text)
142
+
143
+ for i, chunk in enumerate(chunks):
144
+ metadata = {
145
+ "source": Path(file_path).name,
146
+ "chunk_id": i,
147
+ "file_type": "txt"
148
+ }
149
+
150
+ documents.append(Document(page_content=chunk, metadata=metadata))
151
+
152
+ except Exception as e:
153
+ print(f"TXT 로드 중 오류: {str(e)}")
154
+ raise
155
+
156
+ return documents
157
+
158
+ def _load_table(self, file_path: str) -> List[Document]:
159
+ """엑셀/CSV 파일 로드"""
160
+ documents = []
161
+
162
+ try:
163
+ if file_path.endswith('.xlsx'):
164
+ df = pd.read_excel(file_path)
165
+ else:
166
+ df = pd.read_csv(file_path, encoding='utf-8')
167
+
168
+ # 데이터프레임을 텍스트로 변환
169
+ text_parts = []
170
+ text_parts.append(f"파일: {Path(file_path).name}")
171
+ text_parts.append(f"컬럼: {', '.join(df.columns.tolist())}")
172
+
173
+ for index, row in df.iterrows():
174
+ row_text = " | ".join([f"{col}: {val}" for col, val in row.items() if pd.notna(val)])
175
+ text_parts.append(f"행 {index + 1}: {row_text}")
176
+
177
+ full_text = "\n\n".join(text_parts)
178
+
179
+ if full_text.strip():
180
+ chunks = self.text_splitter.split_text(full_text)
181
+
182
+ for i, chunk in enumerate(chunks):
183
+ metadata = {
184
+ "source": Path(file_path).name,
185
+ "chunk_id": i,
186
+ "file_type": "table",
187
+ "total_rows": len(df)
188
+ }
189
+
190
+ documents.append(Document(page_content=chunk, metadata=metadata))
191
+
192
+ except Exception as e:
193
+ print(f"테이블 로드 중 오류: {str(e)}")
194
+ raise
195
+
196
+ return documents
197
+
198
+ def _clean_text(self, text: str) -> str:
199
+ """텍스트 정제"""
200
+ # 불필요한 공백 제거
201
+ text = re.sub(r'\s+', ' ', text)
202
+
203
+ # 특수문자 정리
204
+ text = re.sub(r'[^\w\s\.\,\?\!\:\;\-\(\)\/\&\@]', ' ', text)
205
+
206
+ # 연속된 공백 제거
207
+ text = re.sub(r'\s+', ' ', text).strip()
208
+
209
+ return text
210
+
211
+ def process_documents(self, documents: List[Document]) -> List[Document]:
212
+ """문서 후처리"""
213
+ processed_docs = []
214
+
215
+ for doc in documents:
216
+ content = doc.page_content.strip()
217
+
218
+ if content and len(content) > 20: # 너무 짧은 청크는 제외
219
+ # 복무관리 특화 키워드 강화
220
+ content = self._enhance_fire_service_terms(content)
221
+
222
+ processed_doc = Document(
223
+ page_content=content,
224
+ metadata=doc.metadata
225
+ )
226
+ processed_docs.append(processed_doc)
227
+
228
+ return processed_docs
229
+
230
+ def _enhance_fire_service_terms(self, text: str) -> str:
231
+ """소방 용어 강화"""
232
+ # 복무관리 관련 키워드 매핑
233
+ term_mappings = {
234
+ "연차": "연차휴가",
235
+ "연장": "연장근무",
236
+ "당직": "당직근무",
237
+ "파견": "파견근무",
238
+ "인사": "인사평가",
239
+ "승진": "승진시험",
240
+ "교육": "교육훈련",
241
+ "휴가": "휴가사용",
242
+ "상벌": "상벌규정",
243
+ "징계": "징계절차"
244
+ }
245
+
246
+ enhanced_text = text
247
+ for standard_term, enhanced_term in term_mappings.items():
248
+ enhanced_text = enhanced_text.replace(standard_term, enhanced_term)
249
+
250
+ return enhanced_text
251
+
252
+ # 테스트용 함수
253
+ def test_document_processor():
254
+ """문서 처리기 테스트"""
255
+ processor = DocumentProcessor()
256
+
257
+ # 샘플 documents 폴더 생성
258
+ docs_folder = "documents"
259
+ os.makedirs(docs_folder, exist_ok=True)
260
+
261
+ # 샘플 문서 생성
262
+ sample_text = """
263
+ 복무관리 규정
264
+
265
+ 제1장 총칙
266
+ 제1조 (목적)
267
+ 이 규정은 소방공무원의 복무에 관한 사항을 규정하여 직무수행의 효율성을 높이고
268
+ 조직의 발전에 기여함을 목적으로 한다.
269
+
270
+ 제2조 (근무시간)
271
+ 1. 정규근무시간은 09:00부터 18:00까지로 한다.
272
+ 2. 점심시간은 12:00부터 13:00까지로 한다.
273
+ 3. 당직근무는 정규근무시간 외에 수행하는 근무를 말한다.
274
+
275
+ 제3조 (연차휴가)
276
+ 1. 연차휴가는 1년간 정상 근무한 자에게 15일을 부여한다.
277
+ 2. 연차휴가 사용 시 3일 전까지 신청서를 제출해야 한다.
278
+ 3. 부서장의 승인을 받아 사용한다.
279
+ """
280
+
281
+ with open(os.path.join(docs_folder, "sample_policy.txt"), "w", encoding="utf-8") as f:
282
+ f.write(sample_text)
283
+
284
+ # 문서 로드 테스트
285
+ documents = processor.load_documents_from_folder(docs_folder)
286
+ print(f"✅ {len(documents)}개 문서 청크 생성 완료")
287
+
288
+ return documents
289
+
290
+ if __name__ == "__main__":
291
+ test_document_processor()
gradio_interface.py ADDED
@@ -0,0 +1,329 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import gradio as gr
2
+ import time
3
+ from typing import List, Dict, Tuple
4
+ import os
5
+ from rag_chatbot import RAGChatbot, ChatResponse
6
+
7
+ class GradioInterface:
8
+ """Gradio 웹 인터페이스 클래스"""
9
+
10
+ def __init__(self):
11
+ self.chatbot = RAGChatbot()
12
+ self.is_initialized = False
13
+
14
+ # 예시 질문
15
+ self.example_questions = [
16
+ "연차휴가 사용 방법을 알려주세요",
17
+ "정규근무시간은 어떻게 되나요?",
18
+ "당직근무 절차가 궁금합니다",
19
+ "인사평가는 언제 어떻게 진행되나요?",
20
+ "파견근무 신청 방법을 알려주세요",
21
+ "복무규정 위반 시 어떻게 되나요?"
22
+ ]
23
+
24
+ def initialize_chatbot(self, docs_folder: str = None, force_rebuild: bool = False) -> str:
25
+ """챗봇 초기화"""
26
+ try:
27
+ success = self.chatbot.initialize(docs_folder, force_rebuild)
28
+ if success:
29
+ self.is_initialized = True
30
+ return "✅ RAG 챗봇이 성공적으로 초기화되었습니다!"
31
+ else:
32
+ return "❌ 챗봇 초기화에 실패했습니다. documents 폴더에 파일이 있는지 확인해주세요."
33
+ except Exception as e:
34
+ return f"❌ 초기화 중 오류 발생: {str(e)}"
35
+
36
+ def format_chat_response(self, response: ChatResponse) -> Tuple[str, str]:
37
+ """챗봇 응답을 채팅 형식으로 변환"""
38
+ # 메인 답변
39
+ answer_html = response.answer.replace('\n', '<br>')
40
+
41
+ # 신뢰도 색상
42
+ if response.confidence >= 0.8:
43
+ confidence_color = "green"
44
+ confidence_text = "높음"
45
+ elif response.confidence >= 0.5:
46
+ confidence_color = "orange"
47
+ confidence_text = "보통"
48
+ else:
49
+ confidence_color = "red"
50
+ confidence_text = "낮음"
51
+
52
+ # 정보 메시지
53
+ info_html = f"""
54
+ <div style="background-color: #f0f0f0; padding: 10px; border-radius: 5px; margin-top: 10px;">
55
+ <strong>📊 답변 정보</strong><br>
56
+ • 신뢰도: <span style="color: {confidence_color}; font-weight: bold;">{confidence_text} ({response.confidence:.2%})</span><br>
57
+ • 응답시간: {response.response_time:.2f}초<br>
58
+ • 참고문서: {len(response.sources)}개
59
+ </div>
60
+ """
61
+
62
+ # 출처 정보
63
+ if response.sources:
64
+ sources_html = "<br><strong>📚 참고자료:</strong><ul>"
65
+ for i, source in enumerate(response.sources, 1):
66
+ sources_html += f"<li><strong>{source['source']}</strong> (유사도: {source['similarity']})<br><small>{source['content'][:150]}...</small></li>"
67
+ sources_html += "</ul>"
68
+ info_html += sources_html
69
+
70
+ return answer_html, info_html
71
+
72
+ def chat_interface(self, message: str, history: List[List[str]]) -> List[List[str]]:
73
+ """채팅 인터페이스 핸들러"""
74
+ if not self.is_initialized:
75
+ history.append([message, "⚠️ 챗봇이 초기화되지 않았습니다. 먼저 초기화를 눌러주세요."])
76
+ return history
77
+
78
+ if not message.strip():
79
+ return history
80
+
81
+ try:
82
+ # 답변 생성
83
+ response = self.chatbot.generate_answer(message, use_llm=False) # 템플릿 모드로 가볍게
84
+ answer, info = self.format_chat_response(response)
85
+
86
+ # 전체 응답
87
+ full_response = f"{answer}<br><br>{info}"
88
+
89
+ history.append([message, full_response])
90
+
91
+ except Exception as e:
92
+ error_response = f"❌ 답변 생성 중 오류 발생: {str(e)}"
93
+ history.append([message, error_response])
94
+
95
+ return history
96
+
97
+ def test_question(self, question: str) -> str:
98
+ """예시 질문 테스트"""
99
+ if not self.is_initialized:
100
+ return "⚠️ 먼저 챗봇 초기화를 진행해주세요."
101
+
102
+ try:
103
+ response = self.chatbot.generate_answer(question, use_llm=False)
104
+ answer, info = self.format_chat_response(response)
105
+ return f"❓ 질문: {question}\n\n🤖 답변:\n{answer}\n\n{info}"
106
+ except Exception as e:
107
+ return f"❌ 테스트 중 오류 발생: {str(e)}"
108
+
109
+ def get_chatbot_stats(self) -> str:
110
+ """챗봇 통계 정보"""
111
+ if not self.is_initialized:
112
+ return "챗봇이 초기화되지 않았습니다."
113
+
114
+ try:
115
+ stats = self.chatbot.get_stats()
116
+ stats_html = "<h3>🤖 챗봇 상태 정보</h3>"
117
+
118
+ if stats.get("status") == "initialized":
119
+ vector_stats = stats.get("vector_store", {})
120
+ stats_html += f"""
121
+ <ul>
122
+ <li>상태: <span style="color: green;">✅ 정상 작동 중</span></li>
123
+ <li>총 문서 수: {vector_stats.get('total_documents', 0)}개</li>
124
+ <li>임베딩 모델: {vector_stats.get('embedding_model', 'N/A')}</li>
125
+ <li>벡터 차원: {vector_stats.get('index_dimension', 'N/A')}</li>
126
+ <li>LLM 사용: {'✅' if stats.get('llm_available') else '❌ (템플릿 모드)'}</li>
127
+ </ul>
128
+ """
129
+ else:
130
+ stats_html += "<p>⚠️ 챗봇이 초기화되지 않았습니다.</p>"
131
+
132
+ return stats_html
133
+
134
+ except Exception as e:
135
+ return f"❌ 통계 정보 조회 실패: {str(e)}"
136
+
137
+ def create_interface(self):
138
+ """Gradio 인터페이스 생성"""
139
+ # 커스텀 CSS
140
+ custom_css = """
141
+ .chat-message {
142
+ padding: 15px;
143
+ border-radius: 10px;
144
+ margin: 10px 0;
145
+ }
146
+ .user-message {
147
+ background-color: #e3f2fd;
148
+ border-left: 4px solid #2196f3;
149
+ }
150
+ .assistant-message {
151
+ background-color: #f1f8e9;
152
+ border-left: 4px solid #4caf50;
153
+ }
154
+ .info-box {
155
+ background-color: #fff3e0;
156
+ border: 1px solid #ffb74d;
157
+ border-radius: 8px;
158
+ padding: 12px;
159
+ margin: 10px 0;
160
+ }
161
+ .stats-box {
162
+ background-color: #f5f5f5;
163
+ border-radius: 8px;
164
+ padding: 15px;
165
+ margin: 10px 0;
166
+ }
167
+ """
168
+
169
+ with gr.Blocks(
170
+ title="소방 복무관리 RAG 챗봇",
171
+ theme=gr.themes.Soft(),
172
+ css=custom_css
173
+ ) as interface:
174
+
175
+ gr.Markdown("# 🚒 소방 복무관리 RAG 챗봇")
176
+ gr.Markdown("소방업무 복무관리 규정 및 절차에 대한 질문에 답변해 드립니다.")
177
+
178
+ with gr.Tab("💬 채팅"):
179
+ with gr.Row():
180
+ with gr.Column(scale=4):
181
+ chatbot = gr.Chatbot(
182
+ height=500,
183
+ show_copy_button=True,
184
+ bubble_full_width=False,
185
+ avatar_images=["👤", "🤖"]
186
+ )
187
+ msg = gr.Textbox(
188
+ placeholder="복무관리 관련 질문을 입력해주세요 (예: 연차휴가 사용 방법)",
189
+ label="질문 입력",
190
+ submit_btn="전송"
191
+ )
192
+ with gr.Row():
193
+ submit_btn = gr.Button("💬 전송", variant="primary")
194
+ clear_btn = gr.Button("🗑️ 대화 초기화")
195
+
196
+ with gr.Column(scale=1):
197
+ gr.Markdown("### 🚀 빠른 시작")
198
+ init_btn = gr.Button("🔧 챗봇 초기화", variant="secondary")
199
+ init_status = gr.HTML("⏳ 초기화를 눌러주세요.")
200
+
201
+ gr.Markdown("### 💡 예시 질문")
202
+ for question in self.example_questions:
203
+ gr.Button(question, size="sm").click(
204
+ fn=lambda q=question: q,
205
+ outputs=msg
206
+ )
207
+
208
+ gr.Markdown("### 📊 상태 정보")
209
+ stats_info = gr.HTML("상태 정보를 확인해주세요.")
210
+
211
+ with gr.Tab("🔍 테스트"):
212
+ gr.Markdown("### 예시 질문 테스트")
213
+ with gr.Row():
214
+ with gr.Column():
215
+ test_question = gr.Textbox(
216
+ placeholder="테스트할 질문을 입력하세요",
217
+ label="질문"
218
+ )
219
+ test_btn = gr.Button("🧪 테스트 실행", variant="primary")
220
+ with gr.Column():
221
+ test_result = gr.HTML("테스트 결과가 여기에 표시됩니다.")
222
+
223
+ gr.Markdown("### 통계 정보")
224
+ stats_display = gr.HTML(self.get_chatbot_stats())
225
+
226
+ with gr.Tab("⚙️ 설정"):
227
+ gr.Markdown("### 시스템 설정")
228
+ with gr.Row():
229
+ with gr.Column():
230
+ docs_folder = gr.Textbox(
231
+ value="documents",
232
+ label="문서 폴더 경로"
233
+ )
234
+ force_rebuild = gr.Checkbox(
235
+ label="강제 재구축",
236
+ info="체크 시 기존 인덱스를 새로构建"
237
+ )
238
+ with gr.Column():
239
+ rebuild_btn = gr.Button("🔄 인덱스 재구축", variant="primary")
240
+
241
+ gr.Markdown("### 시스템 정보")
242
+ system_info = gr.HTML("""
243
+ <div class="stats-box">
244
+ <h4>🤖 시스템 사양</h4>
245
+ <ul>
246
+ <li>임베딩 모델: jhgan/ko-sbert-nli</li>
247
+ <li>LLM 모델: beomi/Llama-3-Open-Ko-8B</li>
248
+ <li>청크 크기: 500</li>
249
+ <li>검색 문서 수: 3개</li>
250
+ </ul>
251
+ </div>
252
+ """)
253
+
254
+ # 이벤트 핸들러
255
+ def init_chatbot_wrapper():
256
+ status = self.initialize_chatbot()
257
+ return status, self.get_chatbot_stats()
258
+
259
+ init_btn.click(
260
+ fn=init_chatbot_wrapper,
261
+ outputs=[init_status, stats_info]
262
+ )
263
+
264
+ def rebuild_wrapper(docs_path, force):
265
+ return self.initialize_chatbot(docs_path, force)
266
+
267
+ rebuild_btn.click(
268
+ fn=rebuild_wrapper,
269
+ inputs=[docs_folder, force_rebuild],
270
+ outputs=stats_display
271
+ )
272
+
273
+ # 채팅 이벤트
274
+ def chat_wrapper(message, history):
275
+ return self.chat_interface(message, history), ""
276
+
277
+ msg.submit(
278
+ fn=chat_wrapper,
279
+ inputs=[msg, chatbot],
280
+ outputs=[chatbot, msg]
281
+ )
282
+
283
+ submit_btn.click(
284
+ fn=chat_wrapper,
285
+ inputs=[msg, chatbot],
286
+ outputs=[chatbot, msg]
287
+ )
288
+
289
+ clear_btn.click(
290
+ fn=lambda: ([], ""),
291
+ outputs=[chatbot, msg]
292
+ )
293
+
294
+ # 테스트 이벤트
295
+ test_btn.click(
296
+ fn=self.test_question,
297
+ inputs=test_question,
298
+ outputs=test_result
299
+ )
300
+
301
+ # 자동 새로고침 (통계 정보)
302
+ interface.load(
303
+ fn=self.get_chatbot_stats,
304
+ outputs=stats_info,
305
+ every=30 # 30초마다 새로고침
306
+ )
307
+
308
+ return interface
309
+
310
+ def launch(self, share: bool = False, server_port: int = 7860):
311
+ """인터페이스 실행"""
312
+ interface = self.create_interface()
313
+
314
+ print("🚀 Gradio 웹 인터페이스 시작 중...")
315
+ print(f"📍 접속 주소: http://localhost:{server_port}")
316
+
317
+ interface.launch(
318
+ share=share,
319
+ server_port=server_port,
320
+ show_error=True,
321
+ show_tips=True,
322
+ inbrowser=True
323
+ )
324
+
325
+ # 메인 실행
326
+ if __name__ == "__main__":
327
+ # 웹 인터페이스 실행
328
+ gradio_app = GradioInterface()
329
+ gradio_app.launch(share=False, server_port=7860)
rag_chatbot.py ADDED
@@ -0,0 +1,376 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import re
3
+ from typing import List, Dict, Tuple
4
+ from dataclasses import dataclass
5
+ import torch
6
+ from transformers import AutoTokenizer, AutoModelForCausalLM, pipeline
7
+ from langchain.schema import Document
8
+ from document_processor import DocumentProcessor
9
+ from vector_store import VectorStore
10
+ from config import Config
11
+
12
+ @dataclass
13
+ class ChatResponse:
14
+ """챗봇 응답 결과 클래스"""
15
+ answer: str
16
+ sources: List[Dict]
17
+ confidence: float
18
+ response_time: float
19
+
20
+ class RAGChatbot:
21
+ """소방 복무관리 RAG 챗봇"""
22
+
23
+ def __init__(self):
24
+ self.document_processor = DocumentProcessor(
25
+ chunk_size=Config.CHUNK_SIZE,
26
+ chunk_overlap=Config.CHUNK_OVERLAP
27
+ )
28
+ self.vector_store = VectorStore()
29
+ self.llm = None
30
+ self.llm_tokenizer = None
31
+ self.is_initialized = False
32
+
33
+ def initialize(self, docs_folder: str = None, force_rebuild: bool = False):
34
+ """챗봇 초기화"""
35
+ print("🤖 소방 복무관리 RAG 챗봇 초기화 중...")
36
+
37
+ # 1. 문서 로드 및 처리
38
+ docs_folder = docs_folder or Config.DOCS_FOLDER
39
+ documents = self._load_documents(docs_folder)
40
+
41
+ if not documents:
42
+ print("❌ 처리할 문서가 없습니다. documents 폴더에 파일을 넣어주세요.")
43
+ return False
44
+
45
+ # 2. 벡터 데이터베이스 구축
46
+ success = self.vector_store.rebuild_if_needed(documents, force_rebuild)
47
+ if not success:
48
+ print("❌ 벡터 데이터베이스 구축 실패")
49
+ return False
50
+
51
+ # 3. LLM 모델 로드 (선택적 - 메모리 부족 시 스킵)
52
+ try:
53
+ self._load_llm()
54
+ except Exception as e:
55
+ print(f"⚠️ LLM 모델 로드 실패: {str(e)}")
56
+ print("📝 템플릿 기반 응답 모드로 동작합니다.")
57
+
58
+ self.is_initialized = True
59
+ print("✅ RAG 챗봇 초기화 완료")
60
+ return True
61
+
62
+ def _load_documents(self, docs_folder: str) -> List[Document]:
63
+ """문서 로드 및 처리"""
64
+ if not os.path.exists(docs_folder):
65
+ print(f"⚠️ 문서 폴더가 존재하지 않습니다: {docs_folder}")
66
+ return []
67
+
68
+ print(f"📂 문서 폴더: {docs_folder}")
69
+ raw_documents = self.document_processor.load_documents_from_folder(docs_folder)
70
+ processed_documents = self.document_processor.process_documents(raw_documents)
71
+
72
+ print(f"✅ 총 {len(processed_documents)}개 문서 청크 생성 완료")
73
+ return processed_documents
74
+
75
+ def _load_llm(self):
76
+ """LLM 모델 로드"""
77
+ print(f"🧠 LLM 모델 로드: {Config.LLM_MODEL}")
78
+
79
+ try:
80
+ self.llm_tokenizer = AutoTokenizer.from_pretrained(
81
+ Config.LLM_MODEL,
82
+ trust_remote_code=True
83
+ )
84
+
85
+ # 패딩 토큰 설정
86
+ if self.llm_tokenizer.pad_token is None:
87
+ self.llm_tokenizer.pad_token = self.llm_tokenizer.eos_token
88
+
89
+ self.llm = AutoModelForCausalLM.from_pretrained(
90
+ Config.LLM_MODEL,
91
+ torch_dtype=torch.float16,
92
+ device_map="auto",
93
+ trust_remote_code=True
94
+ )
95
+
96
+ print("✅ LLM 모델 로드 완료")
97
+
98
+ except Exception as e:
99
+ raise Exception(f"LLM 모델 로드 실패: {str(e)}")
100
+
101
+ def search_relevant_docs(self, query: str, k: int = 3) -> List[Tuple[Document, float]]:
102
+ """관련 문서 검색"""
103
+ if not self.is_initialized:
104
+ print("⚠️ 챗봇이 초기화되지 않았습니다.")
105
+ return []
106
+
107
+ # 쿼리 전처리
108
+ processed_query = self._preprocess_query(query)
109
+
110
+ # 벡터 검색
111
+ results = self.vector_store.search_similar(processed_query, k)
112
+
113
+ # 유사도 필터링
114
+ filtered_results = [
115
+ (doc, similarity) for doc, similarity in results
116
+ if similarity > 0.3 # 최소 유사도 임계값
117
+ ]
118
+
119
+ return filtered_results
120
+
121
+ def _preprocess_query(self, query: str) -> str:
122
+ """쿼리 전처리"""
123
+ # 불필요한 공백 제거
124
+ query = re.sub(r'\s+', ' ', query.strip())
125
+
126
+ # 복무관리 관련 키워드 강화
127
+ keyword_mappings = {
128
+ "연차": "연차휴가",
129
+ "휴가": "휴가사용",
130
+ "근무": "근무시간",
131
+ "당직": "당직근무",
132
+ "인사": "인사평가",
133
+ "승진": "승진시험"
134
+ }
135
+
136
+ for keyword, enhanced in keyword_mappings.items():
137
+ if keyword in query and enhanced not in query:
138
+ query = query.replace(keyword, enhanced)
139
+
140
+ return query
141
+
142
+ def generate_answer(self, query: str, use_llm: bool = True) -> ChatResponse:
143
+ """질문에 대한 답변 생성"""
144
+ import time
145
+ start_time = time.time()
146
+
147
+ if not self.is_initialized:
148
+ return ChatResponse(
149
+ answer="죄송합니다. 챗봇이 초기화되지 않았습니다. 관리자에게 문의해주세요.",
150
+ sources=[],
151
+ confidence=0.0,
152
+ response_time=time.time() - start_time
153
+ )
154
+
155
+ # 1. 관련 문서 검색
156
+ relevant_docs = self.search_relevant_docs(query, k=Config.MAX_RETRIEVE_DOCS)
157
+
158
+ if not relevant_docs:
159
+ return ChatResponse(
160
+ answer="죄송합니다. 질문과 관련된 정보를 찾을 수 없습니다. 다른 방식으로 질문해주시거나 관련 부서에 문의해주시기 바랍니다.",
161
+ sources=[],
162
+ confidence=0.0,
163
+ response_time=time.time() - start_time
164
+ )
165
+
166
+ # 2. 답변 생성
167
+ if use_llm and self.llm is not None:
168
+ answer = self._generate_llm_answer(query, relevant_docs)
169
+ else:
170
+ answer = self._generate_template_answer(query, relevant_docs)
171
+
172
+ # 3. 출처 정보 준비
173
+ sources = [
174
+ {
175
+ "source": doc.metadata.get("source", "알 수 없음"),
176
+ "content": doc.page_content[:200] + "..." if len(doc.page_content) > 200 else doc.page_content,
177
+ "similarity": f"{similarity:.4f}"
178
+ }
179
+ for doc, similarity in relevant_docs
180
+ ]
181
+
182
+ # 4. 신뢰도 계산
183
+ confidence = min(sum(similarity for _, similarity in relevant_docs) / len(relevant_docs), 1.0)
184
+
185
+ return ChatResponse(
186
+ answer=answer,
187
+ sources=sources,
188
+ confidence=confidence,
189
+ response_time=time.time() - start_time
190
+ )
191
+
192
+ def _generate_llm_answer(self, query: str, relevant_docs: List[Tuple[Document, float]]) -> str:
193
+ """LLM으로 답변 생성"""
194
+ try:
195
+ # 문맥 구성
196
+ context = "\n\n".join([
197
+ f"[출처 {i+1}] {doc.page_content}"
198
+ for i, (doc, _) in enumerate(relevant_docs)
199
+ ])
200
+
201
+ # 프롬프트 구성
202
+ prompt = f"""{Config.SYSTEM_PROMPT}
203
+
204
+ [참고자료]
205
+ {context}
206
+
207
+ [질문]
208
+ {query}
209
+
210
+ 위 참고자료를 바탕으로 질문에 답변해주세요. 정확하고 친절하게 설명해주세요."""
211
+
212
+ # 토크나이징
213
+ inputs = self.llm_tokenizer(
214
+ prompt,
215
+ return_tensors="pt",
216
+ max_length=2048,
217
+ truncation=True
218
+ )
219
+
220
+ # 생성
221
+ with torch.no_grad():
222
+ outputs = self.llm.generate(
223
+ inputs.input_ids,
224
+ max_new_tokens=512,
225
+ temperature=0.7,
226
+ do_sample=True,
227
+ pad_token_id=self.llm_tokenizer.eos_token_id
228
+ )
229
+
230
+ # 결과 디코딩
231
+ answer = self.llm_tokenizer.decode(
232
+ outputs[0][inputs.input_ids.shape[1]:],
233
+ skip_special_tokens=True
234
+ ).strip()
235
+
236
+ return answer
237
+
238
+ except Exception as e:
239
+ print(f"⚠️ LLM 답변 생성 실패: {str(e)}")
240
+ return self._generate_template_answer(query, relevant_docs)
241
+
242
+ def _generate_template_answer(self, query: str, relevant_docs: List[Tuple[Document, float]]) -> str:
243
+ """템플릿 기반 답변 생성"""
244
+ # 쿼리 분석
245
+ query_lower = query.lower()
246
+
247
+ # 가장 관련성 높은 문서
248
+ top_doc, top_similarity = relevant_docs[0]
249
+
250
+ # 기본 답변 형식
251
+ if "연차" in query_lower or "휴가" in query_lower:
252
+ return self._format_leave_answer(top_doc, query)
253
+ elif "근무시간" in query_lower or "시간" in query_lower:
254
+ return self._format_work_hours_answer(top_doc, query)
255
+ elif "당직" in query_lower:
256
+ return self._format_duty_answer(top_doc, query)
257
+ elif "인사" in query_lower or "평가" in query_lower:
258
+ return self._format_evaluation_answer(top_doc, query)
259
+ else:
260
+ return self._format_general_answer(top_doc, query)
261
+
262
+ def _format_leave_answer(self, doc: Document, query: str) -> str:
263
+ """휴가 관련 답변 형식"""
264
+ content = doc.page_content
265
+
266
+ answer = f"📅 연차휴가 안내\n\n"
267
+
268
+ # 숫자와 관련된 내용 추출
269
+ import re
270
+ days = re.findall(r'(\d+)일', content)
271
+ periods = re.findall(r'(\d+)일 전', content)
272
+
273
+ if days:
274
+ answer += f"• 연차휴가 일수: {days[0]}일\n"
275
+ if periods:
276
+ answer += f"• 신청 기한: {periods[0]}일 전\n"
277
+
278
+ answer += f"\n{content[:300]}..."
279
+
280
+ if len(content) > 300:
281
+ answer += "\n\n📋 자세한 내용은 관련 규정을 확인하시거나 인사담당자에게 문의해주세요."
282
+
283
+ return answer
284
+
285
+ def _format_work_hours_answer(self, doc: Document, query: str) -> str:
286
+ """근무시간 관련 답변 형식"""
287
+ content = doc.page_content
288
+
289
+ answer = f"⏰ 근무시간 안내\n\n"
290
+ answer += f"{content[:400]}..."
291
+
292
+ # 시간 정보 추출
293
+ import re
294
+ times = re.findall(r'\d{2}:\d{2}', content)
295
+ if times:
296
+ answer += f"\n\n🕐 주요 시간: {', '.join(times)}"
297
+
298
+ return answer
299
+
300
+ def _format_duty_answer(self, doc: Document, query: str) -> str:
301
+ """당직 관련 답변 형식"""
302
+ answer = f"🌙 당직근무 안내\n\n"
303
+ answer += f"{doc.page_content[:400]}..."
304
+ answer += "\n\n📞 당직 관련 추가 문의는 관리부서로 연락주세요."
305
+ return answer
306
+
307
+ def _format_evaluation_answer(self, doc: Document, query: str) -> str:
308
+ """인사평가 관련 답변 형식"""
309
+ answer = f"📊 인사평가 안내\n\n"
310
+ answer += f"{doc.page_content[:400]}..."
311
+ answer += "\n\n💡 평가 관련 구체적인 문의는 인사담당자에게 문의해주세요."
312
+ return answer
313
+
314
+ def _format_general_answer(self, doc: Document, query: str) -> str:
315
+ """일반 답변 형식"""
316
+ answer = f"📋 복무관리 안내\n\n"
317
+ answer += f"질문: {query}\n\n"
318
+ answer += f"관련 정보:\n{doc.page_content[:400]}..."
319
+
320
+ if len(doc.page_content) > 400:
321
+ answer += "\n\n📖 더 자세한 정보는 관련 규정 파일을 확인해주세요."
322
+
323
+ return answer
324
+
325
+ def get_stats(self) -> Dict:
326
+ """챗봇 통계 정보"""
327
+ if not self.is_initialized:
328
+ return {"status": "not_initialized"}
329
+
330
+ vector_stats = self.vector_store.get_stats()
331
+
332
+ return {
333
+ "status": "initialized",
334
+ "vector_store": vector_stats,
335
+ "llm_available": self.llm is not None,
336
+ "system_prompt": Config.SYSTEM_PROMPT[:100] + "..."
337
+ }
338
+
339
+ # 테스트용 함수
340
+ def test_rag_chatbot():
341
+ """RAG 챗봇 테스트"""
342
+ # 샘플 문서 폴더 확인
343
+ if not os.path.exists("documents"):
344
+ print("⚠️ documents 폴더가 없습니다. document_processor.py를 먼저 실행해주세요.")
345
+ return
346
+
347
+ # 챗봇 초기화
348
+ chatbot = RAGChatbot()
349
+ success = chatbot.initialize()
350
+
351
+ if not success:
352
+ return
353
+
354
+ # 테스트 질문
355
+ test_questions = [
356
+ "연차휴가는 어떻게 사용하나요?",
357
+ "정규근무시간은 어떻게 되나요?",
358
+ "당직근무가 무엇인가요?",
359
+ "인사평가 절차가 궁금합니다."
360
+ ]
361
+
362
+ # 질문 테스트
363
+ for question in test_questions:
364
+ print(f"\n❓ 질문: {question}")
365
+ response = chatbot.generate_answer(question, use_llm=False) # 템플릿 모드로 테스트
366
+
367
+ print(f"🤖 답변: {response.answer[:300]}...")
368
+ print(f"📊 신뢰도: {response.confidence:.4f}")
369
+ print(f"⏱️ 응답시간: {response.response_time:.4f}초")
370
+ print(f"📚 출처: {len(response.sources)}개")
371
+
372
+ # 통계 정보
373
+ print(f"\n📈 챗봇 통계: {chatbot.get_stats()}")
374
+
375
+ if __name__ == "__main__":
376
+ test_rag_chatbot()
requirements.txt ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # RAG 챗봇을 위한 필수 라이브러리
2
+ langchain>=0.1.0
3
+ langchain-community>=0.0.20
4
+ langchain-huggingface>=0.0.3
5
+ langchain-text-splitters>=0.0.1
6
+
7
+ # 허깅페이스 관련
8
+ transformers>=4.36.0
9
+ torch>=2.1.0
10
+ sentence-transformers>=2.2.2
11
+ huggingface-hub>=0.20.0
12
+
13
+ # 벡터 데이터베이스
14
+ faiss-cpu>=1.7.4
15
+ chromadb>=0.4.18
16
+
17
+ # 웹 인터페이스
18
+ gradio>=4.7.1
19
+ streamlit>=1.28.0
20
+
21
+ # 문서 처리
22
+ pypdf>=3.17.0
23
+ python-docx>=1.1.0
24
+ openpyxl>=3.1.2
25
+ PyMuPDF>=1.23.8
26
+
27
+ # 유틸리티
28
+ python-dotenv>=1.0.0
29
+ tqdm>=4.66.0
30
+ pandas>=2.1.0
vector_store.py ADDED
@@ -0,0 +1,266 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import pickle
3
+ import numpy as np
4
+ from typing import List, Dict, Tuple
5
+ from pathlib import Path
6
+ from sentence_transformers import SentenceTransformer
7
+ import faiss
8
+ from langchain.schema import Document
9
+ from config import Config
10
+
11
+ class VectorStore:
12
+ """FAISS 기반 벡터 데이터베이스 클래스"""
13
+
14
+ def __init__(self, embedding_model: str = None, cache_dir: str = None):
15
+ self.embedding_model_name = embedding_model or Config.EMBEDDING_MODEL
16
+ self.cache_dir = cache_dir or Config.VECTOR_DB_PATH
17
+ self.model = None
18
+ self.index = None
19
+ self.documents = []
20
+ self.doc_ids = []
21
+
22
+ # 캐시 디렉토리 생성
23
+ Path(self.cache_dir).mkdir(parents=True, exist_ok=True)
24
+
25
+ def load_embedding_model(self):
26
+ """임베딩 모델 로드"""
27
+ if self.model is None:
28
+ print(f"📥 임베딩 모델 로드: {self.embedding_model_name}")
29
+ try:
30
+ self.model = SentenceTransformer(self.embedding_model_name)
31
+ print("✅ 임베딩 모델 로드 완료")
32
+ except Exception as e:
33
+ print(f"❌ 임베딩 모델 로드 실패: {str(e)}")
34
+ print("🔄 다국어 모델로 대체 시도...")
35
+ self.model = SentenceTransformer('paraphrase-multilingual-MiniLM-L12-v2')
36
+
37
+ def create_embeddings(self, texts: List[str]) -> np.ndarray:
38
+ """텍스트 목록에 대한 임베딩 생성"""
39
+ if self.model is None:
40
+ self.load_embedding_model()
41
+
42
+ print(f"🔄 {len(texts)}개 텍스트 임베딩 생성 중...")
43
+ embeddings = self.model.encode(
44
+ texts,
45
+ batch_size=32,
46
+ show_progress_bar=True,
47
+ convert_to_numpy=True,
48
+ normalize_embeddings=True
49
+ )
50
+ return embeddings
51
+
52
+ def build_vector_index(self, documents: List[Document]) -> bool:
53
+ """문서 목록으로부터 벡터 인덱스 구축"""
54
+ if not documents:
55
+ print("⚠️ 처리할 문서가 없습니다.")
56
+ return False
57
+
58
+ print(f"🏗️ {len(documents)}개 문서로 벡터 인덱스 구축 시작...")
59
+
60
+ # 문서 저장
61
+ self.documents = documents
62
+
63
+ # 텍스트 추출
64
+ texts = [doc.page_content for doc in documents]
65
+
66
+ # 임베딩 생성
67
+ embeddings = self.create_embeddings(texts)
68
+
69
+ # FAISS 인덱스 생성
70
+ dimension = embeddings.shape[1]
71
+ self.index = faiss.IndexFlatIP(dimension) # 내적 기반 유사도 검색
72
+
73
+ # 임베딩 추가
74
+ self.index.add(embeddings.astype('float32'))
75
+
76
+ # 문서 ID 생성
77
+ self.doc_ids = list(range(len(documents)))
78
+
79
+ print(f"✅ 벡터 인덱스 구축 완료 (차원: {dimension}, 문서: {len(documents)})")
80
+
81
+ # 인덱스 저장
82
+ self.save_index()
83
+
84
+ return True
85
+
86
+ def search_similar(self, query: str, k: int = 5) -> List[Tuple[Document, float]]:
87
+ """유사 문서 검색"""
88
+ if self.index is None:
89
+ print("⚠️ 벡터 인덱스가 생성되지 않았습니다.")
90
+ return []
91
+
92
+ if self.model is None:
93
+ self.load_embedding_model()
94
+
95
+ # 쿼리 임베딩 생성
96
+ query_embedding = self.model.encode([query], normalize_embeddings=True)
97
+ query_embedding = query_embedding.astype('float32')
98
+
99
+ # 검색
100
+ k = min(k, len(self.documents))
101
+ similarities, indices = self.index.search(query_embedding, k)
102
+
103
+ # 결과 변환
104
+ results = []
105
+ for i in range(k):
106
+ idx = indices[0][i]
107
+ similarity = similarities[0][i]
108
+
109
+ if 0 <= idx < len(self.documents):
110
+ doc = self.documents[idx]
111
+ results.append((doc, float(similarity)))
112
+
113
+ return results
114
+
115
+ def save_index(self):
116
+ """벡터 인덱스 및 문서 저장"""
117
+ if self.index is None:
118
+ return
119
+
120
+ try:
121
+ # FAISS 인덱스 저장
122
+ index_path = os.path.join(self.cache_dir, "faiss_index.bin")
123
+ faiss.write_index(self.index, index_path)
124
+
125
+ # 문서 및 메타데이터 저장
126
+ metadata_path = os.path.join(self.cache_dir, "metadata.pkl")
127
+ metadata = {
128
+ 'documents': self.documents,
129
+ 'doc_ids': self.doc_ids,
130
+ 'embedding_model': self.embedding_model_name,
131
+ 'total_documents': len(self.documents)
132
+ }
133
+
134
+ with open(metadata_path, 'wb') as f:
135
+ pickle.dump(metadata, f)
136
+
137
+ print(f"💾 벡터 인덱스 저장 완료: {self.cache_dir}")
138
+
139
+ except Exception as e:
140
+ print(f"❌ 인덱스 저장 실패: {str(e)}")
141
+
142
+ def load_index(self) -> bool:
143
+ """저장된 벡터 인덱스 로드"""
144
+ try:
145
+ index_path = os.path.join(self.cache_dir, "faiss_index.bin")
146
+ metadata_path = os.path.join(self.cache_dir, "metadata.pkl")
147
+
148
+ if not os.path.exists(index_path) or not os.path.exists(metadata_path):
149
+ return False
150
+
151
+ # FAISS 인덱스 로드
152
+ self.index = faiss.read_index(index_path)
153
+
154
+ # 메타데이터 로드
155
+ with open(metadata_path, 'rb') as f:
156
+ metadata = pickle.load(f)
157
+
158
+ self.documents = metadata['documents']
159
+ self.doc_ids = metadata['doc_ids']
160
+ self.embedding_model_name = metadata.get('embedding_model', Config.EMBEDDING_MODEL)
161
+
162
+ # 임베딩 모델 로드
163
+ self.load_embedding_model()
164
+
165
+ print(f"📖 벡터 인덱스 로드 완료 (문서: {len(self.documents)}개)")
166
+ return True
167
+
168
+ except Exception as e:
169
+ print(f"❌ 인덱스 로드 실패: {str(e)}")
170
+ return False
171
+
172
+ def get_stats(self) -> Dict:
173
+ """벡터 데이터베이스 통계 정보"""
174
+ if self.index is None:
175
+ return {"status": "no_index"}
176
+
177
+ return {
178
+ "total_documents": len(self.documents),
179
+ "embedding_model": self.embedding_model_name,
180
+ "index_dimension": self.index.d,
181
+ "cache_directory": self.cache_dir,
182
+ "is_trained": self.index.is_trained if hasattr(self.index, 'is_trained') else True
183
+ }
184
+
185
+ def rebuild_if_needed(self, documents: List[Document], force_rebuild: bool = False) -> bool:
186
+ """필요시 인덱스 재구축"""
187
+ # 기존 인덱스가 있고 강제 재구축이 없는 경우
188
+ if not force_rebuild and self.load_index():
189
+ # 문서 개수가 크게 변경되지 않았으면 재사용
190
+ if abs(len(self.documents) - len(documents)) / max(len(self.documents), 1) < 0.1:
191
+ print("📦 기존 인덱스 재사용")
192
+ return True
193
+
194
+ print("🔄 벡터 인덱스 재구축")
195
+ return self.build_vector_index(documents)
196
+
197
+ def add_documents(self, new_documents: List[Document]) -> bool:
198
+ """새 문서 추가 (동적 업데이트)"""
199
+ if not new_documents:
200
+ return False
201
+
202
+ # 임베딩 생성
203
+ new_texts = [doc.page_content for doc in new_documents]
204
+ new_embeddings = self.create_embeddings(new_texts)
205
+
206
+ if self.index is None:
207
+ # 인덱스가 없으면 새로 생성
208
+ return self.build_vector_index(new_documents)
209
+
210
+ # 기존 인덱스에 추가
211
+ self.index.add(new_embeddings.astype('float32'))
212
+
213
+ # 문서 목록 업데이트
214
+ start_id = len(self.documents)
215
+ self.documents.extend(new_documents)
216
+ self.doc_ids.extend(range(start_id, start_id + len(new_documents)))
217
+
218
+ print(f"➕ {len(new_documents)}개 문서 추가 완료")
219
+
220
+ # 저장
221
+ self.save_index()
222
+ return True
223
+
224
+ def delete_document(self, doc_id: int) -> bool:
225
+ """문서 삭제 (실제로는 인덱스 재구축 필요)"""
226
+ if doc_id < 0 or doc_id >= len(self.documents):
227
+ return False
228
+
229
+ # 해당 문서 제외하고 재구축
230
+ remaining_docs = [doc for i, doc in enumerate(self.documents) if i != doc_id]
231
+ return self.build_vector_index(remaining_docs)
232
+
233
+ # 테스트용 함수
234
+ def test_vector_store():
235
+ """벡터 데이터베이스 테스트"""
236
+ from document_processor import DocumentProcessor
237
+
238
+ # 문서 처리
239
+ processor = DocumentProcessor()
240
+ documents = processor.load_documents_from_folder("documents")
241
+
242
+ if not documents:
243
+ print("⚠️ 테스트할 문서가 없습니다.")
244
+ return
245
+
246
+ # 벡터 데이터베이스 생성
247
+ vector_store = VectorStore()
248
+ vector_store.build_vector_index(documents)
249
+
250
+ # 검색 테스트
251
+ test_queries = [
252
+ "연차휴가 사용 방법",
253
+ "근무시간은 어떻게 되나요?",
254
+ "당직근무 절차"
255
+ ]
256
+
257
+ for query in test_queries:
258
+ print(f"\n🔍 검색: {query}")
259
+ results = vector_store.search_similar(query, k=3)
260
+
261
+ for i, (doc, similarity) in enumerate(results):
262
+ print(f" {i+1}. 유사도: {similarity:.4f}")
263
+ print(f" 내용: {doc.page_content[:100]}...")
264
+
265
+ if __name__ == "__main__":
266
+ test_vector_store()