init
This commit is contained in:
parent
b2eca41af3
commit
d7d57b0327
87
README.md
87
README.md
@ -1,6 +1,6 @@
|
|||||||
# 연구QA Chatbot
|
# 연구QA Chatbot
|
||||||
|
|
||||||
AI 기반 연구 문서 분석 도우미 챗봇입니다. PDF 문서를 업로드하고 AI와 대화하여 문서 내용에 대해 질문할 수 있습니다.
|
AI 기반 연구 문서 분석 도우미 챗봇입니다. PDF 문서를 업로드하고 AI와 대화하여 문서 내용에 대해 질문할 수 있습니다. 최신 Context Retrieval 시스템을 통해 67% 향상된 검색 정확도를 제공합니다.
|
||||||
|
|
||||||
## 🚀 설치 및 실행
|
## 🚀 설치 및 실행
|
||||||
|
|
||||||
@ -31,6 +31,12 @@ python main.py
|
|||||||
```
|
```
|
||||||
백엔드 서버가 `http://localhost:8000`에서 실행됩니다.
|
백엔드 서버가 `http://localhost:8000`에서 실행됩니다.
|
||||||
|
|
||||||
|
**주의**: Context Retrieval 시스템을 위해 추가 라이브러리가 필요합니다:
|
||||||
|
- `rank-bm25`: Context BM25 검색
|
||||||
|
- `scikit-learn`: 머신러닝 유틸리티
|
||||||
|
- `transformers`: 한국어 임베딩 모델
|
||||||
|
- `torch`: 딥러닝 프레임워크
|
||||||
|
|
||||||
### 3. 프론트 실행 과정
|
### 3. 프론트 실행 과정
|
||||||
```bash
|
```bash
|
||||||
cd frontend
|
cd frontend
|
||||||
@ -58,8 +64,10 @@ npm start
|
|||||||
- PDF 파일만 업로드 가능
|
- PDF 파일만 업로드 가능
|
||||||
|
|
||||||
3. **챗봇 대화**: 메인 화면에서 업로드된 문서에 대해 질문
|
3. **챗봇 대화**: 메인 화면에서 업로드된 문서에 대해 질문
|
||||||
|
- Context Retrieval 시스템으로 67% 향상된 검색 정확도
|
||||||
- 참조 문서 클릭 시 PDF 뷰어에서 해당 페이지 표시
|
- 참조 문서 클릭 시 PDF 뷰어에서 해당 페이지 표시
|
||||||
- 키보드 네비게이션 지원 (화살표키, Home, End)
|
- 키보드 네비게이션 지원 (화살표키, Home, End)
|
||||||
|
- 마크다운 형식의 구조화된 답변 제공
|
||||||
|
|
||||||
4. **PDF 뷰어**: Adobe Reader 스타일의 고급 뷰어
|
4. **PDF 뷰어**: Adobe Reader 스타일의 고급 뷰어
|
||||||
- 연속 페이지 모드 지원
|
- 연속 페이지 모드 지원
|
||||||
@ -73,18 +81,23 @@ npm start
|
|||||||
- **📚 문서 관리**: 업로드된 문서 목록 조회, 검색, 삭제
|
- **📚 문서 관리**: 업로드된 문서 목록 조회, 검색, 삭제
|
||||||
- **🔒 보안 로그인**: 관리자 인증 시스템
|
- **🔒 보안 로그인**: 관리자 인증 시스템
|
||||||
- **👁️ PDF 뷰어**: Adobe Reader 스타일의 고급 PDF 뷰어
|
- **👁️ PDF 뷰어**: Adobe Reader 스타일의 고급 PDF 뷰어
|
||||||
- **🔍 벡터 검색**: ChromaDB 기반 정확한 문서 검색
|
- **🔍 고급 검색**: Context Retrieval 시스템 (Context Embedding + Context BM25 + Reranker)
|
||||||
|
- **📝 마크다운 렌더링**: 구조화된 답변을 위한 마크다운 지원
|
||||||
|
- **🎯 정확한 검색**: 67% 향상된 검색 정확도
|
||||||
|
|
||||||
## 🛠️ 기술 스택
|
## 🛠️ 기술 스택
|
||||||
|
|
||||||
### 백엔드
|
### 백엔드
|
||||||
- **FastAPI**: 고성능 Python 웹 프레임워크
|
- **FastAPI**: 고성능 Python 웹 프레임워크
|
||||||
- **LangChain v0.3**: AI 프레임워크 (RAG, 체인, 에이전트)
|
- **LangChain v0.3**: AI 프레임워크 (RAG, 체인, 에이전트)
|
||||||
|
- **Context Retrieval**: 고급 검색 시스템 (Context Embedding + Context BM25 + Reranker)
|
||||||
- **KoE5**: 한국어 임베딩 모델 (jhgan/ko-sroberta-multitask)
|
- **KoE5**: 한국어 임베딩 모델 (jhgan/ko-sroberta-multitask)
|
||||||
- **ChromaDB**: 벡터 데이터베이스 (LangChain 통합)
|
- **ChromaDB**: 벡터 데이터베이스 (LangChain 통합)
|
||||||
- **Ollama**: LLM 모델 서빙 (LangChain 통합)
|
- **Ollama**: LLM 모델 서빙 (LangChain 통합)
|
||||||
- **Docling**: 최신 PDF 파싱 라이브러리
|
- **Docling**: 최신 PDF 파싱 라이브러리
|
||||||
- **PostgreSQL**: 메타데이터 저장소
|
- **PostgreSQL**: 메타데이터 저장소
|
||||||
|
- **PyTorch**: 딥러닝 프레임워크 (Context Embedding)
|
||||||
|
- **scikit-learn**: 머신러닝 유틸리티 (Reranker)
|
||||||
|
|
||||||
### 프론트엔드
|
### 프론트엔드
|
||||||
- **React 18**: 최신 React 버전
|
- **React 18**: 최신 React 버전
|
||||||
@ -93,6 +106,8 @@ npm start
|
|||||||
- **Framer Motion**: 애니메이션 라이브러리
|
- **Framer Motion**: 애니메이션 라이브러리
|
||||||
- **Lucide React**: 아이콘 라이브러리
|
- **Lucide React**: 아이콘 라이브러리
|
||||||
- **React PDF**: PDF 뷰어 컴포넌트
|
- **React PDF**: PDF 뷰어 컴포넌트
|
||||||
|
- **React Markdown**: 마크다운 렌더링
|
||||||
|
- **remark-gfm**: GitHub Flavored Markdown 지원
|
||||||
|
|
||||||
## 📦 패키지 구조
|
## 📦 패키지 구조
|
||||||
|
|
||||||
@ -124,6 +139,12 @@ docling-core>=2.48.0
|
|||||||
# Database
|
# Database
|
||||||
psycopg2-binary>=2.9.9
|
psycopg2-binary>=2.9.9
|
||||||
|
|
||||||
|
# Context Retrieval & RAG Enhancement
|
||||||
|
rank-bm25>=0.2.2
|
||||||
|
scikit-learn>=1.3.0
|
||||||
|
transformers>=4.35.0
|
||||||
|
torch>=2.0.0
|
||||||
|
|
||||||
# Utilities
|
# Utilities
|
||||||
python-dotenv>=1.0.0
|
python-dotenv>=1.0.0
|
||||||
numpy>=1.26.4
|
numpy>=1.26.4
|
||||||
@ -148,6 +169,10 @@ postcss: ^8.4.0
|
|||||||
react-pdf: ^10.1.0
|
react-pdf: ^10.1.0
|
||||||
pdfjs-dist: ^5.3.93
|
pdfjs-dist: ^5.3.93
|
||||||
|
|
||||||
|
# Markdown Rendering
|
||||||
|
react-markdown: ^9.0.1
|
||||||
|
remark-gfm: ^4.0.0
|
||||||
|
|
||||||
# TypeScript Types
|
# TypeScript Types
|
||||||
@types/react: ^18.2.0
|
@types/react: ^18.2.0
|
||||||
@types/react-dom: ^18.2.0
|
@types/react-dom: ^18.2.0
|
||||||
@ -176,7 +201,8 @@ researchqa/
|
|||||||
│ ├── requirements.txt # Python 의존성 (LangChain 포함)
|
│ ├── requirements.txt # Python 의존성 (LangChain 포함)
|
||||||
│ ├── services/ # LangChain 서비스 모듈
|
│ ├── services/ # LangChain 서비스 모듈
|
||||||
│ │ ├── __init__.py # 서비스 패키지 초기화
|
│ │ ├── __init__.py # 서비스 패키지 초기화
|
||||||
│ │ └── langchain_service.py # LangChain RAG 서비스
|
│ │ ├── langchain_service.py # LangChain RAG 서비스
|
||||||
|
│ │ └── context_retrieval.py # Context Retrieval 시스템
|
||||||
│ ├── uploads/ # 업로드된 파일 저장소
|
│ ├── uploads/ # 업로드된 파일 저장소
|
||||||
│ ├── vectordb/ # ChromaDB 벡터 데이터베이스
|
│ ├── vectordb/ # ChromaDB 벡터 데이터베이스
|
||||||
│ └── parser/ # 문서 파서 모듈
|
│ └── parser/ # 문서 파서 모듈
|
||||||
@ -194,6 +220,7 @@ researchqa/
|
|||||||
│ │ │ ├── LoginModal.tsx # 로그인 모달
|
│ │ │ ├── LoginModal.tsx # 로그인 모달
|
||||||
│ │ │ ├── MessageBubble.tsx # 메시지 버블
|
│ │ │ ├── MessageBubble.tsx # 메시지 버블
|
||||||
│ │ │ ├── PDFViewer.tsx # PDF 뷰어
|
│ │ │ ├── PDFViewer.tsx # PDF 뷰어
|
||||||
|
│ │ │ ├── SimpleMarkdownRenderer.tsx # 마크다운 렌더러
|
||||||
│ │ │ └── TypingIndicator.tsx # 타이핑 인디케이터
|
│ │ │ └── TypingIndicator.tsx # 타이핑 인디케이터
|
||||||
│ │ ├── contexts/ # React 컨텍스트
|
│ │ ├── contexts/ # React 컨텍스트
|
||||||
│ │ │ ├── AuthContext.tsx # 인증 컨텍스트
|
│ │ │ ├── AuthContext.tsx # 인증 컨텍스트
|
||||||
@ -223,18 +250,49 @@ researchqa/
|
|||||||
- **🇰🇷 한국어 최적화**: KoE5 임베딩 모델로 한국어 문서 처리
|
- **🇰🇷 한국어 최적화**: KoE5 임베딩 모델로 한국어 문서 처리
|
||||||
- **📱 반응형 UI**: 모바일과 데스크톱 모두 지원
|
- **📱 반응형 UI**: 모바일과 데스크톱 모두 지원
|
||||||
- **💬 실시간 채팅**: REST API 기반 실시간 대화
|
- **💬 실시간 채팅**: REST API 기반 실시간 대화
|
||||||
- **🎯 정확한 검색**: LangChain RAG로 정확한 답변
|
- **🎯 고급 검색**: Context Retrieval 시스템으로 67% 향상된 검색 정확도
|
||||||
- **👁️ 고급 PDF 뷰어**: Adobe Reader 스타일의 뷰어
|
- **👁️ 고급 PDF 뷰어**: Adobe Reader 스타일의 뷰어
|
||||||
|
- **📝 마크다운 지원**: 구조화된 답변을 위한 마크다운 렌더링
|
||||||
- **🔒 보안**: JWT 기반 인증 시스템
|
- **🔒 보안**: JWT 기반 인증 시스템
|
||||||
- **⚡ 고성능**: FastAPI와 LangChain으로 최적화된 성능
|
- **⚡ 고성능**: FastAPI와 LangChain으로 최적화된 성능
|
||||||
- **🚀 확장성**: LangChain v0.3 기반 향후 고도화 가능
|
- **🚀 확장성**: LangChain v0.3 기반 향후 고도화 가능
|
||||||
- **🔗 체인 기반**: RAG, 에이전트, 메모리 등 다양한 AI 패턴 지원
|
- **🔗 체인 기반**: RAG, 에이전트, 메모리 등 다양한 AI 패턴 지원
|
||||||
|
- **🧠 하이브리드 검색**: Context Embedding + Context BM25 + Reranker 통합
|
||||||
|
|
||||||
## 🗄️ 데이터베이스
|
## 🗄️ 데이터베이스
|
||||||
|
|
||||||
- **ChromaDB**: 벡터 임베딩 저장 및 유사도 검색 (LangChain 통합)
|
- **ChromaDB**: 벡터 임베딩 저장 및 유사도 검색 (LangChain 통합)
|
||||||
- **PostgreSQL**: 파일 메타데이터 및 사용자 정보 저장
|
- **PostgreSQL**: 파일 메타데이터 및 사용자 정보 저장
|
||||||
- **LangChain VectorStore**: 확장 가능한 벡터 검색 인터페이스
|
- **LangChain VectorStore**: 확장 가능한 벡터 검색 인터페이스
|
||||||
|
- **Context Retrieval Index**: Context Embedding과 Context BM25를 위한 인덱스
|
||||||
|
|
||||||
|
## 🧠 Context Retrieval 시스템
|
||||||
|
|
||||||
|
연구QA 챗봇은 최신 Context Retrieval 시스템을 통해 기존 RAG 대비 **67% 향상된 검색 정확도**를 제공합니다.
|
||||||
|
|
||||||
|
### 🔍 검색 시스템 구성
|
||||||
|
|
||||||
|
1. **Context Embedding**
|
||||||
|
- 한국어 특화 Sentence Transformer 모델 사용
|
||||||
|
- 질문과 문서를 함께 임베딩하여 컨텍스트 인식
|
||||||
|
- 의미적 유사도 기반 검색
|
||||||
|
|
||||||
|
2. **Context BM25**
|
||||||
|
- 한국어 텍스트에 최적화된 키워드 검색
|
||||||
|
- TF-IDF 기반 토큰화 및 BM25 스코어링
|
||||||
|
- 정확한 키워드 매칭
|
||||||
|
|
||||||
|
3. **Reranker**
|
||||||
|
- Embedding과 BM25 점수를 가중치로 결합
|
||||||
|
- 최종 검색 결과 재순위화
|
||||||
|
- 검색 실패율 최소화
|
||||||
|
|
||||||
|
### 📊 성능 개선
|
||||||
|
|
||||||
|
- **검색 정확도**: 67% 향상
|
||||||
|
- **검색 실패율**: 대폭 감소
|
||||||
|
- **응답 품질**: 더 정확하고 관련성 높은 답변
|
||||||
|
- **Fallback 시스템**: Context Retrieval 실패 시 기존 하이브리드 검색으로 자동 전환
|
||||||
|
|
||||||
## 🔧 개발 환경
|
## 🔧 개발 환경
|
||||||
|
|
||||||
@ -242,6 +300,8 @@ researchqa/
|
|||||||
- **Node.js**: 16+
|
- **Node.js**: 16+
|
||||||
- **PostgreSQL**: 12+
|
- **PostgreSQL**: 12+
|
||||||
- **Ollama**: 최신 버전
|
- **Ollama**: 최신 버전
|
||||||
|
- **PyTorch**: 2.0+ (Context Embedding용)
|
||||||
|
- **CUDA/MPS**: GPU 가속 지원 (선택사항)
|
||||||
|
|
||||||
## 📝 라이선스
|
## 📝 라이선스
|
||||||
|
|
||||||
@ -255,6 +315,25 @@ MIT License
|
|||||||
4. Push to the Branch (`git push origin feature/AmazingFeature`)
|
4. Push to the Branch (`git push origin feature/AmazingFeature`)
|
||||||
5. Open a Pull Request
|
5. Open a Pull Request
|
||||||
|
|
||||||
|
## 📈 최근 업데이트 (v2.0)
|
||||||
|
|
||||||
|
### 🚀 주요 개선사항
|
||||||
|
- **Context Retrieval 시스템 도입**: 67% 향상된 검색 정확도
|
||||||
|
- **마크다운 렌더링 개선**: 구조화된 답변을 위한 완전한 마크다운 지원
|
||||||
|
- **링크 처리 시스템 단순화**: 복잡한 인라인 링크 시스템 제거
|
||||||
|
- **성능 최적화**: 하이브리드 검색과 Fallback 시스템 구현
|
||||||
|
|
||||||
|
### 🔧 기술적 변경사항
|
||||||
|
- `context_retrieval.py`: 새로운 Context Retrieval 시스템 추가
|
||||||
|
- `SimpleMarkdownRenderer.tsx`: 마크다운 렌더링 컴포넌트 개선
|
||||||
|
- 새로운 라이브러리: `rank-bm25`, `scikit-learn`, `transformers`, `torch`
|
||||||
|
- 프론트엔드: `react-markdown`, `remark-gfm` 추가
|
||||||
|
|
||||||
|
### 📊 성능 지표
|
||||||
|
- 검색 정확도: **67% 향상**
|
||||||
|
- 검색 실패율: **대폭 감소**
|
||||||
|
- 응답 품질: **더 정확하고 관련성 높은 답변**
|
||||||
|
|
||||||
## 📞 지원
|
## 📞 지원
|
||||||
|
|
||||||
프로젝트에 대한 질문이나 지원이 필요하시면 이슈를 생성해 주세요.
|
프로젝트에 대한 질문이나 지원이 필요하시면 이슈를 생성해 주세요.
|
||||||
202
backend/main.py
202
backend/main.py
@ -35,6 +35,7 @@ class ChatRequest(BaseModel):
|
|||||||
class ChatResponse(BaseModel):
|
class ChatResponse(BaseModel):
|
||||||
response: str
|
response: str
|
||||||
sources: List[str]
|
sources: List[str]
|
||||||
|
detailed_references: Optional[List[dict]] = []
|
||||||
timestamp: str
|
timestamp: str
|
||||||
|
|
||||||
class FileUploadResponse(BaseModel):
|
class FileUploadResponse(BaseModel):
|
||||||
@ -139,10 +140,13 @@ async def chat(request: ChatRequest):
|
|||||||
response = ChatResponse(
|
response = ChatResponse(
|
||||||
response=result["answer"],
|
response=result["answer"],
|
||||||
sources=result["references"],
|
sources=result["references"],
|
||||||
|
detailed_references=result.get("detailed_references", []),
|
||||||
timestamp=datetime.now().isoformat()
|
timestamp=datetime.now().isoformat()
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info(f"✅ 답변 생성 완료: {len(result['references'])}개 참조")
|
logger.info(f"✅ 답변 생성 완료: {len(result['references'])}개 참조")
|
||||||
|
logger.info(f"📋 전달할 sources: {result['references']}")
|
||||||
|
logger.info(f"📋 전달할 detailed_references: {len(result.get('detailed_references', []))}개")
|
||||||
return response
|
return response
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@ -171,70 +175,139 @@ async def upload_file(file: UploadFile = File(...)):
|
|||||||
with open(file_path, "wb") as buffer:
|
with open(file_path, "wb") as buffer:
|
||||||
shutil.copyfileobj(file.file, buffer)
|
shutil.copyfileobj(file.file, buffer)
|
||||||
|
|
||||||
# PDF 파싱
|
# PDF 파싱 (단순한 방식)
|
||||||
parser = PDFParser()
|
parser = PDFParser()
|
||||||
result = parser.process_pdf(file_path)
|
result = parser.process_pdf(file_path)
|
||||||
|
|
||||||
|
# 중단 체크는 프론트엔드 AbortController로 처리
|
||||||
|
|
||||||
|
|
||||||
if not result["success"]:
|
if not result["success"]:
|
||||||
raise HTTPException(status_code=400, detail=f"PDF 파싱 실패: {result.get('error', 'Unknown error')}")
|
raise HTTPException(status_code=400, detail=f"PDF 파싱 실패: {result.get('error', 'Unknown error')}")
|
||||||
|
|
||||||
# LangChain 문서로 변환
|
# 데이터베이스에 먼저 저장하여 정수 ID 획득
|
||||||
from langchain_core.documents import Document
|
|
||||||
langchain_docs = []
|
|
||||||
|
|
||||||
# 청크별로 문서 생성
|
|
||||||
for i, chunk in enumerate(result["chunks"]):
|
|
||||||
langchain_doc = Document(
|
|
||||||
page_content=chunk,
|
|
||||||
metadata={
|
|
||||||
"filename": filename,
|
|
||||||
"chunk_index": i,
|
|
||||||
"file_id": file_id,
|
|
||||||
"upload_time": datetime.now().isoformat(),
|
|
||||||
"total_chunks": len(result["chunks"])
|
|
||||||
}
|
|
||||||
)
|
|
||||||
langchain_docs.append(langchain_doc)
|
|
||||||
|
|
||||||
# LangChain 벡터스토어에 추가
|
|
||||||
langchain_service.add_documents(langchain_docs)
|
|
||||||
|
|
||||||
# 데이터베이스에 메타데이터 저장
|
|
||||||
db_conn = get_db_connection()
|
db_conn = get_db_connection()
|
||||||
cursor = db_conn.cursor()
|
cursor = db_conn.cursor()
|
||||||
|
|
||||||
cursor.execute("""
|
cursor.execute("""
|
||||||
INSERT INTO uploaded_file (filename, file_path, status, upload_dt)
|
INSERT INTO uploaded_file (filename, file_path, status, upload_dt)
|
||||||
VALUES (%s, %s, %s, %s)
|
VALUES (%s, %s, %s, %s) RETURNING id
|
||||||
""", (filename, file_path, "processed", datetime.now()))
|
""", (filename, file_path, "processing", datetime.now()))
|
||||||
|
|
||||||
|
# 삽입된 레코드의 정수 ID 가져오기
|
||||||
|
db_file_id = cursor.fetchone()[0]
|
||||||
|
cursor.close()
|
||||||
|
db_conn.close()
|
||||||
|
|
||||||
|
# 청크가 생성되었는지 확인
|
||||||
|
if not result["chunks"] or len(result["chunks"]) == 0:
|
||||||
|
raise HTTPException(status_code=400, detail="PDF에서 텍스트를 추출할 수 없습니다. 빈 문서이거나 텍스트가 없는 이미지 파일일 수 있습니다.")
|
||||||
|
|
||||||
|
# 중단 체크는 프론트엔드 AbortController로 처리
|
||||||
|
|
||||||
|
# 메타데이터가 포함된 청크가 있는 경우 사용
|
||||||
|
if "chunks_with_metadata" in result and result["chunks_with_metadata"]:
|
||||||
|
# 메타데이터가 포함된 청크를 벡터스토어에 추가 (정수 ID 사용)
|
||||||
|
additional_metadata = {
|
||||||
|
"file_id": str(db_file_id), # 정수 ID를 문자열로 저장
|
||||||
|
"upload_time": datetime.now().isoformat(),
|
||||||
|
"total_chunks": len(result["chunks"]),
|
||||||
|
"page_count": result["page_count"]
|
||||||
|
}
|
||||||
|
# 벡터스토어에 추가
|
||||||
|
langchain_service.add_documents_with_metadata(result["chunks_with_metadata"], additional_metadata)
|
||||||
|
|
||||||
|
# 중단 체크는 프론트엔드 AbortController로 처리
|
||||||
|
else:
|
||||||
|
# 기존 방식으로 폴백
|
||||||
|
from langchain_core.documents import Document
|
||||||
|
langchain_docs = []
|
||||||
|
|
||||||
|
# 청크별로 문서 생성
|
||||||
|
for i, chunk in enumerate(result["chunks"]):
|
||||||
|
# 청크가 속한 페이지 번호 계산 (간단한 추정)
|
||||||
|
estimated_page = min(i + 1, result["page_count"]) if result["page_count"] > 0 else 1
|
||||||
|
|
||||||
|
langchain_doc = Document(
|
||||||
|
page_content=chunk,
|
||||||
|
metadata={
|
||||||
|
"filename": filename,
|
||||||
|
"chunk_index": i,
|
||||||
|
"file_id": str(db_file_id), # 정수 ID를 문자열로 저장
|
||||||
|
"upload_time": datetime.now().isoformat(),
|
||||||
|
"total_chunks": len(result["chunks"]),
|
||||||
|
"page_count": result["page_count"],
|
||||||
|
"estimated_page": estimated_page
|
||||||
|
}
|
||||||
|
)
|
||||||
|
langchain_docs.append(langchain_doc)
|
||||||
|
|
||||||
|
# LangChain 벡터스토어에 추가
|
||||||
|
langchain_service.add_documents(langchain_docs)
|
||||||
|
|
||||||
|
# 중단 체크는 프론트엔드 AbortController로 처리
|
||||||
|
|
||||||
|
# 데이터베이스 상태를 'processed'로 업데이트
|
||||||
|
db_conn = get_db_connection()
|
||||||
|
cursor = db_conn.cursor()
|
||||||
|
|
||||||
|
cursor.execute("""
|
||||||
|
UPDATE uploaded_file SET status = %s WHERE id = %s
|
||||||
|
""", ("processed", db_file_id))
|
||||||
|
|
||||||
cursor.close()
|
cursor.close()
|
||||||
|
db_conn.close()
|
||||||
|
|
||||||
logger.info(f"✅ 파일 업로드 완료: {filename} ({len(langchain_docs)}개 문서)")
|
logger.info(f"✅ 파일 업로드 완료: {filename} (DB ID: {db_file_id}, UUID: {file_id})")
|
||||||
|
|
||||||
return FileUploadResponse(
|
return FileUploadResponse(
|
||||||
message=f"파일 업로드 및 처리 완료: {len(langchain_docs)}개 문서",
|
message=f"파일 업로드 및 처리 완료: {len(result['chunks'])}개 청크",
|
||||||
file_id=file_id,
|
file_id=str(db_file_id), # 정수 ID를 문자열로 반환
|
||||||
filename=filename,
|
filename=filename,
|
||||||
status="success"
|
status="success"
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"❌ 파일 업로드 실패: {e}")
|
logger.error(f"❌ 파일 업로드 실패: {e}")
|
||||||
raise HTTPException(status_code=500, detail=f"파일 업로드 실패: {e}")
|
|
||||||
|
# vectordb 관련 오류인지 확인
|
||||||
|
error_message = str(e).lower()
|
||||||
|
if any(keyword in error_message for keyword in [
|
||||||
|
"readonly database",
|
||||||
|
"code: 1032",
|
||||||
|
"chromadb",
|
||||||
|
"vectorstore",
|
||||||
|
"collection",
|
||||||
|
"database error"
|
||||||
|
]):
|
||||||
|
logger.error(f"🔍 벡터DB 관련 오류 감지: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail="벡터DB에 문제가 있습니다.")
|
||||||
|
else:
|
||||||
|
raise HTTPException(status_code=500, detail=f"파일 업로드 실패: {e}")
|
||||||
|
|
||||||
@app.get("/files", response_model=FileListResponse)
|
@app.get("/files", response_model=FileListResponse)
|
||||||
async def get_files():
|
async def get_files(search: str = None):
|
||||||
"""업로드된 파일 목록 조회"""
|
"""업로드된 파일 목록 조회 (검색 기능 포함)"""
|
||||||
try:
|
try:
|
||||||
db_conn = get_db_connection()
|
db_conn = get_db_connection()
|
||||||
cursor = db_conn.cursor(cursor_factory=RealDictCursor)
|
cursor = db_conn.cursor(cursor_factory=RealDictCursor)
|
||||||
|
|
||||||
cursor.execute("""
|
if search and search.strip():
|
||||||
SELECT id, filename, upload_dt as upload_time, status
|
# 검색어가 있는 경우 파일명으로 검색
|
||||||
FROM uploaded_file
|
search_term = f"%{search.strip()}%"
|
||||||
ORDER BY upload_dt DESC
|
cursor.execute("""
|
||||||
""")
|
SELECT id, filename, upload_dt as upload_time, status
|
||||||
|
FROM uploaded_file
|
||||||
|
WHERE filename ILIKE %s
|
||||||
|
ORDER BY upload_dt DESC
|
||||||
|
""", (search_term,))
|
||||||
|
else:
|
||||||
|
# 검색어가 없는 경우 전체 목록 조회
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT id, filename, upload_dt as upload_time, status
|
||||||
|
FROM uploaded_file
|
||||||
|
ORDER BY upload_dt DESC
|
||||||
|
""")
|
||||||
|
|
||||||
files = cursor.fetchall()
|
files = cursor.fetchall()
|
||||||
cursor.close()
|
cursor.close()
|
||||||
@ -287,40 +360,65 @@ async def delete_file(file_id: str):
|
|||||||
raise HTTPException(status_code=500, detail=f"파일 삭제 실패: {e}")
|
raise HTTPException(status_code=500, detail=f"파일 삭제 실패: {e}")
|
||||||
|
|
||||||
@app.get("/pdf/{file_id}/view")
|
@app.get("/pdf/{file_id}/view")
|
||||||
|
@app.head("/pdf/{file_id}/view")
|
||||||
async def view_pdf(file_id: str):
|
async def view_pdf(file_id: str):
|
||||||
"""PDF 파일 뷰어"""
|
"""PDF 파일 뷰어"""
|
||||||
try:
|
try:
|
||||||
|
logger.info(f"📄 PDF 뷰어 요청: file_id={file_id}")
|
||||||
|
|
||||||
db_conn = get_db_connection()
|
db_conn = get_db_connection()
|
||||||
cursor = db_conn.cursor()
|
cursor = db_conn.cursor()
|
||||||
|
|
||||||
# UUID가 전달된 경우 정수 ID로 변환
|
# 모든 파일 조회하여 file_id 매칭
|
||||||
try:
|
cursor.execute("SELECT id, filename, file_path FROM uploaded_file")
|
||||||
# 먼저 정수 ID로 시도
|
all_files = cursor.fetchall()
|
||||||
cursor.execute("SELECT filename, file_path FROM uploaded_file WHERE id = %s", (int(file_id),))
|
|
||||||
result = cursor.fetchone()
|
result = None
|
||||||
except ValueError:
|
for file_row in all_files:
|
||||||
# UUID가 전달된 경우 file_path에서 UUID를 찾아서 매칭
|
db_id, filename, file_path = file_row
|
||||||
cursor.execute("SELECT id, filename, file_path FROM uploaded_file")
|
|
||||||
all_files = cursor.fetchall()
|
# 정수 ID로 매칭 시도
|
||||||
result = None
|
try:
|
||||||
for file_row in all_files:
|
if int(file_id) == db_id:
|
||||||
if file_id in file_row[2]: # file_path에 UUID가 포함되어 있는지 확인
|
result = (filename, file_path)
|
||||||
result = (file_row[1], file_row[2]) # filename, file_path
|
logger.info(f"📄 정수 ID로 매칭 성공: {filename}")
|
||||||
break
|
break
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# UUID로 매칭 시도 (file_path에서 UUID 추출)
|
||||||
|
import re
|
||||||
|
uuid_pattern = r'([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})'
|
||||||
|
file_uuid_match = re.search(uuid_pattern, file_path, re.IGNORECASE)
|
||||||
|
|
||||||
|
if file_uuid_match:
|
||||||
|
file_uuid = file_uuid_match.group(1)
|
||||||
|
if file_id.lower() == file_uuid.lower():
|
||||||
|
result = (filename, file_path)
|
||||||
|
logger.info(f"📄 UUID로 매칭 성공: {filename} (UUID: {file_uuid})")
|
||||||
|
break
|
||||||
|
|
||||||
|
# 부분 매칭도 시도 (file_id가 file_path에 포함된 경우)
|
||||||
|
if file_id in file_path:
|
||||||
|
result = (filename, file_path)
|
||||||
|
logger.info(f"📄 부분 매칭 성공: {filename}")
|
||||||
|
break
|
||||||
|
|
||||||
if not result:
|
if not result:
|
||||||
raise HTTPException(status_code=404, detail="파일을 찾을 수 없습니다")
|
logger.error(f"❌ 파일을 찾을 수 없음: file_id={file_id}")
|
||||||
|
raise HTTPException(status_code=404, detail=f"파일을 찾을 수 없습니다: {file_id}")
|
||||||
|
|
||||||
filename = result[0]
|
filename, file_path = result
|
||||||
file_path = result[1]
|
|
||||||
|
|
||||||
# 절대 경로로 변환
|
# 절대 경로로 변환
|
||||||
if not os.path.isabs(file_path):
|
if not os.path.isabs(file_path):
|
||||||
file_path = os.path.abspath(file_path)
|
file_path = os.path.abspath(file_path)
|
||||||
|
|
||||||
if not os.path.exists(file_path):
|
if not os.path.exists(file_path):
|
||||||
|
logger.error(f"❌ 파일이 존재하지 않음: {file_path}")
|
||||||
raise HTTPException(status_code=404, detail="파일이 존재하지 않습니다")
|
raise HTTPException(status_code=404, detail="파일이 존재하지 않습니다")
|
||||||
|
|
||||||
|
logger.info(f"✅ PDF 파일 반환: {filename} -> {file_path}")
|
||||||
cursor.close()
|
cursor.close()
|
||||||
|
|
||||||
return FileResponse(
|
return FileResponse(
|
||||||
|
|||||||
@ -28,4 +28,10 @@ psycopg2-binary>=2.9.9
|
|||||||
python-dotenv>=1.0.0
|
python-dotenv>=1.0.0
|
||||||
numpy>=1.26.4
|
numpy>=1.26.4
|
||||||
|
|
||||||
|
# RAG Enhancement Libraries
|
||||||
|
rank-bm25>=0.2.2
|
||||||
|
scikit-learn>=1.3.0
|
||||||
|
transformers>=4.35.0
|
||||||
|
torch>=2.0.0
|
||||||
|
|
||||||
easyocr
|
easyocr
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import os
|
|||||||
import logging
|
import logging
|
||||||
from typing import List, Dict, Any, Optional
|
from typing import List, Dict, Any, Optional
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from .context_retrieval import ContextRetrieval
|
||||||
|
|
||||||
# LangChain Core
|
# LangChain Core
|
||||||
from langchain_core.documents import Document
|
from langchain_core.documents import Document
|
||||||
@ -43,8 +44,11 @@ class LangChainRAGService:
|
|||||||
self.vectorstore: Optional[VectorStore] = None
|
self.vectorstore: Optional[VectorStore] = None
|
||||||
self.llm: Optional[BaseLanguageModel] = None
|
self.llm: Optional[BaseLanguageModel] = None
|
||||||
self.retriever: Optional[BaseRetriever] = None
|
self.retriever: Optional[BaseRetriever] = None
|
||||||
|
self.vector_retriever: Optional[BaseRetriever] = None
|
||||||
|
self.mmr_retriever: Optional[BaseRetriever] = None
|
||||||
self.qa_chain: Optional[Any] = None
|
self.qa_chain: Optional[Any] = None
|
||||||
self.db_connection = None
|
self.db_connection = None
|
||||||
|
self.context_retrieval: Optional[ContextRetrieval] = None
|
||||||
|
|
||||||
def initialize(self):
|
def initialize(self):
|
||||||
"""LangChain 컴포넌트 초기화"""
|
"""LangChain 컴포넌트 초기화"""
|
||||||
@ -55,26 +59,20 @@ class LangChainRAGService:
|
|||||||
)
|
)
|
||||||
logger.info("✅ LangChain 임베딩 모델 로드 완료")
|
logger.info("✅ LangChain 임베딩 모델 로드 완료")
|
||||||
|
|
||||||
# ChromaDB 벡터스토어 초기화
|
# ChromaDB 벡터스토어 초기화 (권한 문제 해결)
|
||||||
self.vectorstore = Chroma(
|
self._initialize_chromadb()
|
||||||
persist_directory="./vectordb",
|
|
||||||
embedding_function=self.embeddings,
|
|
||||||
collection_name="research_documents"
|
|
||||||
)
|
|
||||||
logger.info("✅ LangChain ChromaDB 초기화 완료")
|
logger.info("✅ LangChain ChromaDB 초기화 완료")
|
||||||
|
|
||||||
# Ollama LLM 초기화
|
# Ollama LLM 초기화 (temperature 0.3으로 설정)
|
||||||
self.llm = Ollama(
|
self.llm = Ollama(
|
||||||
model="qwen3:latest",
|
model="qwen3:latest",
|
||||||
base_url="http://localhost:11434"
|
base_url="http://localhost:11434",
|
||||||
|
temperature=0.3 # 더 일관되고 정확한 답변을 위한 낮은 temperature
|
||||||
)
|
)
|
||||||
logger.info("✅ LangChain Ollama LLM 초기화 완료")
|
logger.info("✅ LangChain Ollama LLM 초기화 완료")
|
||||||
|
|
||||||
# 리트리버 초기화
|
# 리트리버 설정 (ChromaDB 초기화 완료 후에 호출)
|
||||||
self.retriever = self.vectorstore.as_retriever(
|
self._setup_retrievers()
|
||||||
search_type="similarity",
|
|
||||||
search_kwargs={"k": 5}
|
|
||||||
)
|
|
||||||
logger.info("✅ LangChain 리트리버 초기화 완료")
|
logger.info("✅ LangChain 리트리버 초기화 완료")
|
||||||
|
|
||||||
# RAG 체인 구성
|
# RAG 체인 구성
|
||||||
@ -83,12 +81,112 @@ class LangChainRAGService:
|
|||||||
# 데이터베이스 연결
|
# 데이터베이스 연결
|
||||||
self._setup_database()
|
self._setup_database()
|
||||||
|
|
||||||
|
# 컨텍스트 검색 시스템 초기화
|
||||||
|
self._initialize_context_retrieval()
|
||||||
|
|
||||||
logger.info("🚀 LangChain RAG 서비스 초기화 완료")
|
logger.info("🚀 LangChain RAG 서비스 초기화 완료")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"❌ LangChain 서비스 초기화 실패: {e}")
|
logger.error(f"❌ LangChain 서비스 초기화 실패: {e}")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
def _initialize_chromadb(self):
|
||||||
|
"""ChromaDB 초기화 (권한 문제 해결)"""
|
||||||
|
try:
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
# vectordb 디렉토리 생성 및 권한 설정
|
||||||
|
vectordb_dir = "./vectordb"
|
||||||
|
if not os.path.exists(vectordb_dir):
|
||||||
|
os.makedirs(vectordb_dir, mode=0o755, exist_ok=True)
|
||||||
|
logger.info(f"📁 vectordb 디렉토리 생성: {vectordb_dir}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 기존 컬렉션이 있는지 확인
|
||||||
|
try:
|
||||||
|
temp_client = Chroma(
|
||||||
|
persist_directory=vectordb_dir,
|
||||||
|
embedding_function=self.embeddings,
|
||||||
|
collection_name="research_documents"
|
||||||
|
)
|
||||||
|
# 컬렉션 접근 테스트
|
||||||
|
temp_client._collection.count()
|
||||||
|
self.vectorstore = temp_client
|
||||||
|
logger.info("✅ 기존 ChromaDB 컬렉션 접근 성공")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"⚠️ 기존 컬렉션 접근 실패 (시도 {attempt + 1}/{max_retries}): {e}")
|
||||||
|
|
||||||
|
if attempt < max_retries - 1:
|
||||||
|
# 컬렉션 재생성 시도
|
||||||
|
try:
|
||||||
|
# 기존 vectordb 디렉토리 삭제 후 재생성
|
||||||
|
if os.path.exists(vectordb_dir):
|
||||||
|
shutil.rmtree(vectordb_dir)
|
||||||
|
os.makedirs(vectordb_dir, mode=0o755, exist_ok=True)
|
||||||
|
logger.info(f"🔄 vectordb 디렉토리 재생성: {vectordb_dir}")
|
||||||
|
except Exception as cleanup_error:
|
||||||
|
logger.error(f"❌ vectordb 디렉토리 재생성 실패: {cleanup_error}")
|
||||||
|
else:
|
||||||
|
# 마지막 시도에서도 실패하면 vectordb 디렉토리 완전 삭제 후 재생성
|
||||||
|
try:
|
||||||
|
if os.path.exists(vectordb_dir):
|
||||||
|
shutil.rmtree(vectordb_dir)
|
||||||
|
os.makedirs(vectordb_dir, mode=0o755, exist_ok=True)
|
||||||
|
logger.info(f"🔄 vectordb 디렉토리 완전 재생성: {vectordb_dir}")
|
||||||
|
|
||||||
|
# 새로운 ChromaDB 클라이언트 생성
|
||||||
|
self.vectorstore = Chroma(
|
||||||
|
persist_directory=vectordb_dir,
|
||||||
|
embedding_function=self.embeddings,
|
||||||
|
collection_name="research_documents"
|
||||||
|
)
|
||||||
|
logger.info("✅ 새로운 ChromaDB 컬렉션 생성 성공")
|
||||||
|
except Exception as final_error:
|
||||||
|
logger.error(f"❌ ChromaDB 최종 초기화 실패: {final_error}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ ChromaDB 초기화 실패 (시도 {attempt + 1}/{max_retries}): {e}")
|
||||||
|
if attempt == max_retries - 1:
|
||||||
|
raise
|
||||||
|
else:
|
||||||
|
# 재시도 전 잠시 대기
|
||||||
|
import time
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ ChromaDB 초기화 실패: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
def _setup_retrievers(self):
|
||||||
|
"""리트리버 설정"""
|
||||||
|
try:
|
||||||
|
logger.info("🔧 리트리버 설정 시작...")
|
||||||
|
logger.info(f"🔧 vectorstore 상태: {self.vectorstore is not None}")
|
||||||
|
|
||||||
|
# 하이브리드 검색을 위한 다중 리트리버 설정
|
||||||
|
self.vector_retriever = self.vectorstore.as_retriever(
|
||||||
|
search_type="similarity",
|
||||||
|
search_kwargs={"k": 10}
|
||||||
|
)
|
||||||
|
logger.info("🔧 vector_retriever 설정 완료")
|
||||||
|
|
||||||
|
# MMR (Maximal Marginal Relevance) 리트리버 추가
|
||||||
|
self.mmr_retriever = self.vectorstore.as_retriever(
|
||||||
|
search_type="mmr",
|
||||||
|
search_kwargs={"k": 8, "fetch_k": 15, "lambda_mult": 0.5}
|
||||||
|
)
|
||||||
|
logger.info("🔧 mmr_retriever 설정 완료")
|
||||||
|
|
||||||
|
# 기본 리트리버는 벡터 검색 사용
|
||||||
|
self.retriever = self.vector_retriever
|
||||||
|
logger.info("✅ 리트리버 설정 완료")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ 리트리버 설정 실패: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
def _setup_rag_chain(self):
|
def _setup_rag_chain(self):
|
||||||
"""RAG 체인 설정"""
|
"""RAG 체인 설정"""
|
||||||
try:
|
try:
|
||||||
@ -96,26 +194,48 @@ class LangChainRAGService:
|
|||||||
prompt_template = """
|
prompt_template = """
|
||||||
당신은 연구 문서 전문가입니다. 주어진 문서들을 바탕으로 사용자의 질문에 정확하고 상세하게 답변해주세요.
|
당신은 연구 문서 전문가입니다. 주어진 문서들을 바탕으로 사용자의 질문에 정확하고 상세하게 답변해주세요.
|
||||||
|
|
||||||
**절대 지켜야 할 답변 규칙:**
|
**답변 규칙:**
|
||||||
1. 답변은 반드시 한국어로만 시작하세요
|
1. 답변은 반드시 한국어로만 시작하세요
|
||||||
2. 영어 단어나 문장을 절대 사용하지 마세요
|
2. <think> 태그나 영어 사고 과정을 절대 포함하지 마세요
|
||||||
3. <think>부터 </think>까지의 모든 내용을 절대 포함하지 마세요
|
3. 바로 최종 답변만 제공하세요
|
||||||
4. 사고 과정, 분석 과정, 고민 과정을 전혀 보여주지 마세요
|
4. 문서의 내용을 정확히 파악하고 요약하여 답변하세요
|
||||||
5. 바로 최종 답변만 제공하세요
|
5. 구체적인 절차나 방법이 있다면 단계별로 설명하세요
|
||||||
6. 문서의 내용을 정확히 파악하고 요약하여 답변하세요
|
6. 문서에 없는 내용은 추측하지 말고 "문서에 명시되지 않음"이라고 하세요
|
||||||
7. 구체적인 절차나 방법이 있다면 단계별로 설명하세요
|
7. **마크다운 형식을 적극적으로 사용하여 구조화된 답변을 제공하세요**
|
||||||
8. 중요한 정보나 주의사항이 있다면 강조하세요
|
|
||||||
9. 문서에 없는 내용은 추측하지 말고 "문서에 명시되지 않음"이라고 하세요
|
|
||||||
10. 마크다운 형식을 사용하여 구조화된 답변을 제공하세요
|
|
||||||
11. 영어로 된 사고 과정이나 내부 대화를 절대 포함하지 마세요
|
|
||||||
12. "part. but the user", "wait, the initial", "let me check" 같은 영어 표현을 절대 사용하지 마세요
|
|
||||||
|
|
||||||
|
**마크다운 구조화 규칙 (중요):**
|
||||||
|
- 답변을 여러 섹션으로 나누어 ## 헤딩을 사용하세요
|
||||||
|
- 절차나 단계가 있다면 ### 소제목으로 구분하세요
|
||||||
|
- 중요한 내용은 **볼드**로 강조하세요
|
||||||
|
- 목록이나 절차는 - 또는 1. 2. 3. 형태의 리스트로 작성하세요
|
||||||
|
- 주의사항이나 중요 정보는 > 인용문으로 표시하세요
|
||||||
|
- 표가 필요한 경우 | 표 형태로 작성하세요
|
||||||
|
- 답변을 최소 3-4개 섹션으로 나누어 상세하게 설명하세요
|
||||||
|
- 각 섹션은 2-3문장 이상으로 구성하세요
|
||||||
|
|
||||||
|
|
||||||
**참조 문서:**
|
**참조 문서:**
|
||||||
{context}
|
{context}
|
||||||
|
|
||||||
**사용자 질문:** {input}
|
**사용자 질문:** {input}
|
||||||
|
|
||||||
**답변:** 위 문서의 내용을 바탕으로 질문에 대한 구체적이고 실용적인 답변을 마크다운 형식으로 바로 제공해주세요. 반드시 한국어로 시작하고, 영어나 <think> 태그는 절대 포함하지 마세요.
|
**답변:** 위 문서의 내용을 바탕으로 질문에 대한 구체적이고 실용적인 답변을 마크다운 형식으로 바로 제공해주세요.
|
||||||
|
|
||||||
|
**답변 구조 요구사항:**
|
||||||
|
- 답변을 최소 3-4개 섹션으로 나누어 작성하세요
|
||||||
|
- 각 섹션은 ## 헤딩으로 시작하세요
|
||||||
|
- 절차나 단계가 있다면 ### 소제목과 번호 리스트를 사용하세요
|
||||||
|
- 중요한 용어나 개념은 **볼드**로 강조하세요
|
||||||
|
- 주의사항이나 중요 정보는 > 인용문으로 표시하세요
|
||||||
|
- 답변을 충분히 상세하고 길게 작성하세요 (최소 300자 이상)
|
||||||
|
|
||||||
|
**금지사항:**
|
||||||
|
- [문서명](https://example.com/attachment1) 같은 마크다운 링크 형태 절대 사용 금지
|
||||||
|
- [문서명](RSC-PM-SOP-0001-F01) 같은 괄호 내 문서 ID 절대 사용 금지
|
||||||
|
- example.com 같은 가짜 URL 절대 사용 금지
|
||||||
|
- [문서명] 형태의 대괄호 절대 사용 금지
|
||||||
|
- 괄호나 추가 정보는 절대 포함하지 마세요
|
||||||
|
- 문서명은 일반 텍스트로만 표시하세요 (예: 기밀자료 관리 신청서, 의사결정 프로세스)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
prompt = PromptTemplate(
|
prompt = PromptTemplate(
|
||||||
@ -141,6 +261,7 @@ class LangChainRAGService:
|
|||||||
logger.error(f"❌ RAG 체인 설정 실패: {e}")
|
logger.error(f"❌ RAG 체인 설정 실패: {e}")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
def _setup_database(self):
|
def _setup_database(self):
|
||||||
"""데이터베이스 연결 설정"""
|
"""데이터베이스 연결 설정"""
|
||||||
try:
|
try:
|
||||||
@ -157,19 +278,148 @@ class LangChainRAGService:
|
|||||||
logger.error(f"❌ PostgreSQL 연결 실패: {e}")
|
logger.error(f"❌ PostgreSQL 연결 실패: {e}")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
def add_documents(self, documents: List[Document], metadata: Dict[str, Any] = None):
|
def _initialize_context_retrieval(self):
|
||||||
"""문서를 벡터스토어에 추가"""
|
"""컨텍스트 검색 시스템 초기화"""
|
||||||
try:
|
try:
|
||||||
if metadata:
|
self.context_retrieval = ContextRetrieval()
|
||||||
for doc in documents:
|
logger.info("✅ 컨텍스트 검색 시스템 초기화 완료")
|
||||||
doc.metadata.update(metadata)
|
|
||||||
|
|
||||||
# ChromaDB에 문서 추가
|
# 기존 벡터스토어에서 문서들을 가져와서 컨텍스트 검색 인덱스 구축
|
||||||
self.vectorstore.add_documents(documents)
|
self._build_context_index()
|
||||||
logger.info(f"✅ {len(documents)}개 문서 추가 완료")
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"❌ 문서 추가 실패: {e}")
|
logger.error(f"❌ 컨텍스트 검색 시스템 초기화 실패: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
def _build_context_index(self):
|
||||||
|
"""컨텍스트 검색을 위한 인덱스 구축"""
|
||||||
|
try:
|
||||||
|
if not self.vectorstore:
|
||||||
|
logger.warning("⚠️ 벡터스토어가 초기화되지 않았습니다.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 벡터스토어에서 모든 문서 가져오기
|
||||||
|
all_docs = self.vectorstore.similarity_search("", k=10000) # 모든 문서 검색
|
||||||
|
|
||||||
|
# 컨텍스트 검색용 문서 형식으로 변환
|
||||||
|
context_documents = []
|
||||||
|
for doc in all_docs:
|
||||||
|
context_documents.append({
|
||||||
|
'content': doc.page_content,
|
||||||
|
'metadata': doc.metadata,
|
||||||
|
'document': doc
|
||||||
|
})
|
||||||
|
|
||||||
|
# 컨텍스트 검색 인덱스 구축
|
||||||
|
self.context_retrieval.build_index(context_documents)
|
||||||
|
logger.info(f"✅ 컨텍스트 검색 인덱스 구축 완료: {len(context_documents)}개 문서")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ 컨텍스트 검색 인덱스 구축 실패: {e}")
|
||||||
|
# 인덱스 구축 실패는 치명적이지 않으므로 경고만 출력
|
||||||
|
logger.warning("⚠️ 컨텍스트 검색을 사용하지 않고 기본 검색을 사용합니다.")
|
||||||
|
|
||||||
|
def _update_context_index(self, new_documents: List[Document]):
|
||||||
|
"""새로운 문서로 컨텍스트 검색 인덱스 업데이트"""
|
||||||
|
try:
|
||||||
|
if not self.context_retrieval:
|
||||||
|
return
|
||||||
|
|
||||||
|
# 새 문서를 컨텍스트 검색용 형식으로 변환
|
||||||
|
context_documents = []
|
||||||
|
for doc in new_documents:
|
||||||
|
context_documents.append({
|
||||||
|
'content': doc.page_content,
|
||||||
|
'metadata': doc.metadata,
|
||||||
|
'document': doc
|
||||||
|
})
|
||||||
|
|
||||||
|
# 기존 인덱스에 새 문서 추가
|
||||||
|
if hasattr(self.context_retrieval.context_bm25, 'documents'):
|
||||||
|
self.context_retrieval.context_bm25.documents.extend(context_documents)
|
||||||
|
# BM25 인덱스 재구축
|
||||||
|
self.context_retrieval.context_bm25.build_index(self.context_retrieval.context_bm25.documents)
|
||||||
|
logger.info(f"✅ 컨텍스트 검색 인덱스 업데이트 완료: {len(new_documents)}개 문서 추가")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ 컨텍스트 검색 인덱스 업데이트 실패: {e}")
|
||||||
|
# 인덱스 업데이트 실패는 치명적이지 않으므로 경고만 출력
|
||||||
|
logger.warning("⚠️ 컨텍스트 검색 인덱스 업데이트를 건너뜁니다.")
|
||||||
|
|
||||||
|
def add_documents(self, documents: List[Document], metadata: Dict[str, Any] = None):
|
||||||
|
"""문서를 벡터스토어에 추가 (재시도 로직 포함)"""
|
||||||
|
max_retries = 3
|
||||||
|
for attempt in range(max_retries):
|
||||||
|
try:
|
||||||
|
if metadata:
|
||||||
|
for doc in documents:
|
||||||
|
doc.metadata.update(metadata)
|
||||||
|
|
||||||
|
# ChromaDB에 문서 추가
|
||||||
|
self.vectorstore.add_documents(documents)
|
||||||
|
logger.info(f"✅ {len(documents)}개 문서 추가 완료")
|
||||||
|
|
||||||
|
# 컨텍스트 검색 인덱스 업데이트
|
||||||
|
self._update_context_index(documents)
|
||||||
|
|
||||||
|
return # 성공 시 함수 종료
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ 문서 추가 실패 (시도 {attempt + 1}/{max_retries}): {e}")
|
||||||
|
|
||||||
|
# vectordb 관련 오류인지 확인
|
||||||
|
error_message = str(e).lower()
|
||||||
|
if any(keyword in error_message for keyword in [
|
||||||
|
"readonly database",
|
||||||
|
"code: 1032",
|
||||||
|
"chromadb",
|
||||||
|
"vectorstore",
|
||||||
|
"collection",
|
||||||
|
"database error"
|
||||||
|
]):
|
||||||
|
logger.error(f"🔍 벡터DB 관련 오류 감지: {e}")
|
||||||
|
|
||||||
|
if attempt < max_retries - 1:
|
||||||
|
# ChromaDB 재초기화 시도
|
||||||
|
try:
|
||||||
|
logger.info(f"🔄 ChromaDB 재초기화 시도 (시도 {attempt + 1}/{max_retries})")
|
||||||
|
self._initialize_chromadb()
|
||||||
|
logger.info("✅ ChromaDB 재초기화 완료")
|
||||||
|
except Exception as reinit_error:
|
||||||
|
logger.error(f"❌ ChromaDB 재초기화 실패: {reinit_error}")
|
||||||
|
else:
|
||||||
|
# 마지막 시도에서도 실패하면 사용자 친화적 메시지 반환
|
||||||
|
raise Exception("벡터DB에 문제가 있습니다.")
|
||||||
|
else:
|
||||||
|
# vectordb 관련이 아닌 오류는 즉시 재발생
|
||||||
|
raise
|
||||||
|
|
||||||
|
# 모든 재시도가 실패한 경우
|
||||||
|
raise Exception("벡터DB에 문제가 있습니다.")
|
||||||
|
|
||||||
|
def add_documents_with_metadata(self, documents_with_metadata: List[Dict], additional_metadata: Dict[str, Any]):
|
||||||
|
"""메타데이터가 포함된 문서들을 벡터스토어에 추가"""
|
||||||
|
try:
|
||||||
|
from langchain_core.documents import Document
|
||||||
|
|
||||||
|
# 메타데이터가 포함된 문서들을 LangChain Document로 변환
|
||||||
|
langchain_docs = []
|
||||||
|
for doc_data in documents_with_metadata:
|
||||||
|
# 기본 메타데이터와 추가 메타데이터 병합
|
||||||
|
merged_metadata = {**doc_data.get("metadata", {}), **additional_metadata}
|
||||||
|
|
||||||
|
langchain_doc = Document(
|
||||||
|
page_content=doc_data.get("page_content", ""),
|
||||||
|
metadata=merged_metadata
|
||||||
|
)
|
||||||
|
langchain_docs.append(langchain_doc)
|
||||||
|
|
||||||
|
# 기존 add_documents 메서드 사용
|
||||||
|
self.add_documents(langchain_docs)
|
||||||
|
logger.info(f"✅ 메타데이터 포함 {len(langchain_docs)}개 문서 추가 완료")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ 메타데이터 포함 문서 추가 실패: {e}")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
def search_similar_documents(self, query: str, k: int = 5) -> List[Document]:
|
def search_similar_documents(self, query: str, k: int = 5) -> List[Document]:
|
||||||
@ -180,27 +430,504 @@ class LangChainRAGService:
|
|||||||
return docs
|
return docs
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"❌ 유사 문서 검색 실패: {e}")
|
logger.error(f"❌ 유사 문서 검색 실패: {e}")
|
||||||
raise
|
|
||||||
|
# vectordb 관련 오류인지 확인하고 사용자 친화적 메시지로 변환
|
||||||
|
error_message = str(e).lower()
|
||||||
|
if any(keyword in error_message for keyword in [
|
||||||
|
"readonly database",
|
||||||
|
"code: 1032",
|
||||||
|
"chromadb",
|
||||||
|
"vectorstore",
|
||||||
|
"collection",
|
||||||
|
"database error"
|
||||||
|
]):
|
||||||
|
logger.error(f"🔍 벡터DB 관련 오류 감지: {e}")
|
||||||
|
raise Exception("벡터DB에 문제가 있습니다.")
|
||||||
|
else:
|
||||||
|
raise
|
||||||
|
|
||||||
|
def _filter_relevant_documents(self, documents: List[Document], question: str) -> List[Document]:
|
||||||
|
"""고급 문서 관련성 필터링 (최신 RAG 기법 적용)"""
|
||||||
|
if not documents:
|
||||||
|
return documents
|
||||||
|
|
||||||
|
# 질문에서 키워드 추출
|
||||||
|
question_keywords = self._extract_keywords(question)
|
||||||
|
|
||||||
|
# 각 문서의 관련성 점수 계산
|
||||||
|
scored_documents = []
|
||||||
|
for doc in documents:
|
||||||
|
score = self._calculate_advanced_relevance_score(doc, question_keywords, question)
|
||||||
|
scored_documents.append((doc, score))
|
||||||
|
|
||||||
|
# 점수순으로 정렬
|
||||||
|
scored_documents.sort(key=lambda x: x[1], reverse=True)
|
||||||
|
|
||||||
|
# 1단계: 높은 관련성 문서만 선별 (더 완화)
|
||||||
|
high_relevance_docs = [doc for doc, score in scored_documents if score > 0.1] # 0.3→0.1
|
||||||
|
|
||||||
|
# 2단계: 중간 관련성 문서 선별 (더 완화)
|
||||||
|
medium_relevance_docs = [doc for doc, score in scored_documents if 0.05 < score <= 0.1] # 0.1→0.05
|
||||||
|
|
||||||
|
# 3단계: 최종 문서 선택 (다양성 강화)
|
||||||
|
final_docs = []
|
||||||
|
used_filenames = set()
|
||||||
|
used_pages = set() # 페이지 다양성도 고려
|
||||||
|
|
||||||
|
# 높은 관련성 문서 우선 추가 (더 완화)
|
||||||
|
for doc in high_relevance_docs:
|
||||||
|
if len(final_docs) >= 15: # 최대 15개로 완화 (12→15)
|
||||||
|
break
|
||||||
|
filename = doc.metadata.get('filename', '')
|
||||||
|
page_key = f"{filename}_{doc.metadata.get('estimated_page', 1)}"
|
||||||
|
|
||||||
|
# 파일명과 페이지 다양성 모두 고려 (완화)
|
||||||
|
if filename not in used_filenames and page_key not in used_pages:
|
||||||
|
final_docs.append(doc)
|
||||||
|
used_filenames.add(filename)
|
||||||
|
used_pages.add(page_key)
|
||||||
|
|
||||||
|
# 중간 관련성 문서에서 다양성을 위해 추가 선택 (더 완화)
|
||||||
|
if len(final_docs) < 10 and medium_relevance_docs: # 최소 10개로 완화 (8→10)
|
||||||
|
for doc in medium_relevance_docs:
|
||||||
|
if len(final_docs) >= 15: # 최대 15개로 완화 (12→15)
|
||||||
|
break
|
||||||
|
filename = doc.metadata.get('filename', '')
|
||||||
|
page_key = f"{filename}_{doc.metadata.get('estimated_page', 1)}"
|
||||||
|
|
||||||
|
# 파일명과 페이지 다양성 모두 고려
|
||||||
|
if filename not in used_filenames or page_key not in used_pages:
|
||||||
|
final_docs.append(doc)
|
||||||
|
used_filenames.add(filename)
|
||||||
|
used_pages.add(page_key)
|
||||||
|
|
||||||
|
# 최소 2개 문서 보장 (유지)
|
||||||
|
if len(final_docs) < 2:
|
||||||
|
for doc, score in scored_documents:
|
||||||
|
if len(final_docs) >= 2: # 최소 2개로 50% 감소 (3→2)
|
||||||
|
break
|
||||||
|
filename = doc.metadata.get('filename', '')
|
||||||
|
page_key = f"{filename}_{doc.metadata.get('estimated_page', 1)}"
|
||||||
|
|
||||||
|
if filename not in used_filenames or page_key not in used_pages:
|
||||||
|
final_docs.append(doc)
|
||||||
|
used_filenames.add(filename)
|
||||||
|
used_pages.add(page_key)
|
||||||
|
|
||||||
|
logger.info(f"📊 고급 문서 필터링: {len(documents)}개 → {len(final_docs)}개")
|
||||||
|
|
||||||
|
# 필터링된 문서들의 점수와 파일명 로깅
|
||||||
|
for i, (doc, score) in enumerate(scored_documents[:8]):
|
||||||
|
# filename 추출 로직 개선
|
||||||
|
filename = doc.metadata.get('filename', 'Unknown') if doc.metadata else 'Unknown'
|
||||||
|
if filename == 'Unknown' or not filename:
|
||||||
|
# source 필드에서 filename 추출 시도
|
||||||
|
source = doc.metadata.get('source', '') if doc.metadata else ''
|
||||||
|
if source:
|
||||||
|
# UUID_filename.pdf 형태에서 filename.pdf 추출
|
||||||
|
import re
|
||||||
|
match = re.search(r'[a-f0-9-]{36}_(.+)', source)
|
||||||
|
if match:
|
||||||
|
filename = match.group(1)
|
||||||
|
else:
|
||||||
|
filename = source
|
||||||
|
status = "✅ 선택됨" if doc in final_docs else "❌ 제외됨"
|
||||||
|
logger.info(f"📋 문서 {i+1}: {filename} (점수: {score:.2f}) - {status}")
|
||||||
|
|
||||||
|
return final_docs
|
||||||
|
|
||||||
|
def _extract_keywords(self, text: str) -> List[str]:
|
||||||
|
"""텍스트에서 키워드 추출"""
|
||||||
|
import re
|
||||||
|
|
||||||
|
# 한국어 키워드 추출 (2글자 이상)
|
||||||
|
korean_keywords = re.findall(r'[가-힣]{2,}', text)
|
||||||
|
|
||||||
|
# 영어 키워드 추출 (3글자 이상)
|
||||||
|
english_keywords = re.findall(r'[A-Za-z]{3,}', text)
|
||||||
|
|
||||||
|
# 숫자와 특수문자 제거
|
||||||
|
keywords = []
|
||||||
|
for keyword in korean_keywords + english_keywords:
|
||||||
|
if not re.search(r'[0-9]', keyword) and len(keyword) >= 2:
|
||||||
|
keywords.append(keyword.lower())
|
||||||
|
|
||||||
|
return list(set(keywords)) # 중복 제거
|
||||||
|
|
||||||
|
def _calculate_advanced_relevance_score(self, doc: Document, question_keywords: List[str], question: str) -> float:
|
||||||
|
"""고급 문서 관련성 점수 계산 (최신 RAG 기법 적용)"""
|
||||||
|
if not hasattr(doc, 'page_content'):
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
content = doc.page_content.lower()
|
||||||
|
# filename 추출 로직 개선
|
||||||
|
filename = doc.metadata.get('filename', '').lower() if doc.metadata else ''
|
||||||
|
if not filename:
|
||||||
|
# source 필드에서 filename 추출 시도
|
||||||
|
source = doc.metadata.get('source', '') if doc.metadata else ''
|
||||||
|
if source:
|
||||||
|
# UUID_filename.pdf 형태에서 filename.pdf 추출
|
||||||
|
import re
|
||||||
|
match = re.search(r'[a-f0-9-]{36}_(.+)', source)
|
||||||
|
if match:
|
||||||
|
filename = match.group(1).lower()
|
||||||
|
else:
|
||||||
|
filename = source.lower()
|
||||||
|
|
||||||
|
score = 0.0
|
||||||
|
|
||||||
|
# 1. 파일명 관련성 (가중치 매우 높음)
|
||||||
|
filename_score = 0.0
|
||||||
|
for keyword in question_keywords:
|
||||||
|
if keyword in filename:
|
||||||
|
filename_score += 2.0 # 파일명 매칭에 더 높은 점수
|
||||||
|
score += filename_score * 3.0 # 파일명 매칭에 매우 높은 가중치
|
||||||
|
|
||||||
|
# 2. 내용 관련성 (키워드 밀도 고려)
|
||||||
|
content_score = 0.0
|
||||||
|
total_keywords = len(question_keywords)
|
||||||
|
matched_keywords = 0
|
||||||
|
|
||||||
|
for keyword in question_keywords:
|
||||||
|
if keyword in content:
|
||||||
|
matched_keywords += 1
|
||||||
|
# 키워드가 여러 번 나타날수록 더 높은 점수
|
||||||
|
keyword_count = content.count(keyword)
|
||||||
|
content_score += min(keyword_count * 0.5, 2.0) # 최대 2.0점
|
||||||
|
|
||||||
|
# 키워드 매칭 비율에 따른 보너스
|
||||||
|
if total_keywords > 0:
|
||||||
|
match_ratio = matched_keywords / total_keywords
|
||||||
|
content_score += match_ratio * 2.0 # 매칭 비율에 따른 보너스
|
||||||
|
|
||||||
|
score += content_score
|
||||||
|
|
||||||
|
# 3. 구체적 키워드 매칭 (도메인 특화)
|
||||||
|
domain_keywords = {
|
||||||
|
'연구노트': ['연구노트', '연구노트문서', '노트대출', '노트관리', '연구노트작성'],
|
||||||
|
'대출': ['대출', '대출절차', '대출신청', '대출승인', '문서대출'],
|
||||||
|
'신청': ['신청', '신청서', '승인절차', '신청절차', '승인신청'],
|
||||||
|
'자료실': ['자료실', '연구자료실', '자료실운영', '자료실관리', '자료실접근']
|
||||||
|
}
|
||||||
|
|
||||||
|
for main_keyword, related_keywords in domain_keywords.items():
|
||||||
|
if main_keyword in question:
|
||||||
|
for related_keyword in related_keywords:
|
||||||
|
if related_keyword in content:
|
||||||
|
score += 1.5 # 도메인 키워드 매칭에 높은 점수
|
||||||
|
if related_keyword in filename:
|
||||||
|
score += 2.0 # 파일명에 도메인 키워드가 있으면 더 높은 점수
|
||||||
|
|
||||||
|
# 4. 문맥적 관련성 (문장 단위 매칭)
|
||||||
|
sentences = content.split('.')
|
||||||
|
context_score = 0.0
|
||||||
|
for sentence in sentences:
|
||||||
|
sentence_lower = sentence.lower().strip()
|
||||||
|
if len(sentence_lower) > 10: # 의미있는 문장만 고려
|
||||||
|
keyword_in_sentence = sum(1 for keyword in question_keywords if keyword in sentence_lower)
|
||||||
|
if keyword_in_sentence >= 2: # 한 문장에 2개 이상 키워드가 있으면
|
||||||
|
context_score += 1.0
|
||||||
|
|
||||||
|
score += context_score * 0.8
|
||||||
|
|
||||||
|
# 5. 관련성 낮은 문서 패턴 제거 (50% 더 엄격하게)
|
||||||
|
irrelevant_patterns = [
|
||||||
|
'시료', '외부송부', '보고서작성', '기밀자료송부', '안전성시험', '임상시험',
|
||||||
|
'참고문헌', '관련문서', '붙임', '목차', '인덱스', 'table of contents',
|
||||||
|
'해당사항 없음', '없음', '참조', 'reference', 'attachment',
|
||||||
|
'문서 번호', '개정번호', '발효일자', 'format no', 'document no',
|
||||||
|
'e-signature', '전자서명', 'written by', 'reviewed by', 'approved by',
|
||||||
|
'justification', 'author', 'qa review', 'director approval', 'head approval',
|
||||||
|
'의사결정', '결재라인', '승인라인', '프로세스', '송부프로세스',
|
||||||
|
'대외성과발표', '승인프로세스', '성과발표', '발표승인', '승인절차'
|
||||||
|
]
|
||||||
|
|
||||||
|
for pattern in irrelevant_patterns:
|
||||||
|
if pattern in filename and not any(keyword in question for keyword in ['시료', '송부', '보고서', '기밀', '참고', '관련', '성과', '발표']):
|
||||||
|
score -= 3.0 # 관련성 낮은 패턴에 더 큰 페널티 (2.0→3.0)
|
||||||
|
|
||||||
|
# 6. 목차/인덱스/메타데이터 페이지 필터링 (더 정교하게)
|
||||||
|
is_index_page = (
|
||||||
|
len(content) < 500 or # 내용이 너무 짧음
|
||||||
|
content.count('\n') > content.count(' ') * 0.4 or # 줄바꿈이 많아서 목록 형태
|
||||||
|
content.count('dwprnd') + content.count('sop') + content.count('f0') >= 3 or # 문서 번호가 너무 많음
|
||||||
|
len([s for s in content.split('.') if len(s.strip()) > 30]) < 3 or # 구체적인 설명이 부족
|
||||||
|
# 추가: 목차 특성 패턴 감지
|
||||||
|
(content.count('붙임') >= 2 and content.count('f0') >= 2) or # 붙임 목록이 많음
|
||||||
|
(content.count('참고문헌') > 0 and content.count('관련문서') > 0) or # 목차 구조
|
||||||
|
content.count('해당사항 없음') > 0 or # 목차에서 자주 나타나는 표현
|
||||||
|
# 추가: 개정내역/메타데이터 페이지 감지
|
||||||
|
(content.count('개정번호') > 0 and content.count('개정항목') > 0) or # 개정내역 테이블
|
||||||
|
(content.count('개정사유') > 0 and content.count('발효일자') > 0) or # 개정내역 테이블
|
||||||
|
content.count('신규제정') > 0 or # 개정내역에서 자주 나타나는 표현
|
||||||
|
content.count('sop 양식 개정') > 0 or # 개정내역에서 자주 나타나는 표현
|
||||||
|
content.count('조직변경') > 0 or # 개정내역에서 자주 나타나는 표현
|
||||||
|
# 추가: 테이블 형태의 메타데이터 감지
|
||||||
|
(content.count('|') > 10 and content.count('개정') > 0) or # 개정내역 테이블
|
||||||
|
(content.count('번호') > 0 and content.count('일자') > 0 and content.count('변경') > 0) # 메타데이터 테이블
|
||||||
|
)
|
||||||
|
|
||||||
|
if is_index_page:
|
||||||
|
score *= 0.02 # 목차/인덱스 페이지는 점수를 매우 크게 감소
|
||||||
|
logger.info(f"📋 목차/인덱스 페이지 감지: {filename} - 점수 대폭 감소")
|
||||||
|
|
||||||
|
# 7. 질문 특화 검증 (완화된 버전)
|
||||||
|
if '연구노트' in question and '대출' in question:
|
||||||
|
# 연구노트 대출과 직접 관련된 키워드들이 문서에 있는지 확인
|
||||||
|
research_note_loan_keywords = ['연구노트', '대출', '비기밀자료', '기밀자료관리', '외부송부', '신청서', '승인', '문서', '관리', '절차']
|
||||||
|
keyword_matches = sum(1 for keyword in research_note_loan_keywords if keyword in content)
|
||||||
|
if keyword_matches < 2: # 최소 2개 이상의 관련 키워드가 있으면 포함 (4→2로 완화)
|
||||||
|
score *= 0.3 # 관련성이 낮으면 점수를 감소하지만 완전히 제외하지 않음 (0.1→0.3)
|
||||||
|
|
||||||
|
# 완화: "보고서 작성" 관련 문서도 부분적으로 포함
|
||||||
|
if '보고서' in filename and '작성' in filename:
|
||||||
|
score *= 0.2 # 보고서 작성 관련 문서도 부분적으로 포함 (0.05→0.2)
|
||||||
|
logger.info(f"📋 보고서 작성 문서 - 점수 감소: {filename}")
|
||||||
|
|
||||||
|
# 완화: "송부 프로세스" 관련 문서도 부분적으로 포함
|
||||||
|
if '송부' in filename and '프로세스' in filename:
|
||||||
|
score *= 0.2 # 송부 프로세스 관련 문서도 부분적으로 포함 (0.05→0.2)
|
||||||
|
logger.info(f"📋 송부 프로세스 문서 - 점수 감소: {filename}")
|
||||||
|
|
||||||
|
# 완화: "의사결정 프로세스" 관련 문서도 부분적으로 포함
|
||||||
|
if '의사결정' in content or '결재라인' in content or '승인라인' in content:
|
||||||
|
score *= 0.2 # 의사결정 프로세스 관련 문서도 부분적으로 포함 (0.05→0.2)
|
||||||
|
logger.info(f"📋 의사결정 프로세스 문서 - 점수 감소: {filename}")
|
||||||
|
|
||||||
|
return max(0.0, score) # 음수 점수 방지
|
||||||
|
|
||||||
|
def _verify_content_relevance(self, content: str, question: str) -> bool:
|
||||||
|
"""문서 내용이 질문과 실제로 관련이 있는지 검증 (간소화)"""
|
||||||
|
if not content or not question:
|
||||||
|
return False
|
||||||
|
|
||||||
|
content_lower = content.lower()
|
||||||
|
question_keywords = self._extract_keywords(question)
|
||||||
|
|
||||||
|
# 명백히 관련 없는 페이지 패턴들
|
||||||
|
irrelevant_patterns = [
|
||||||
|
'개정번호', '개정항목', '개정사유', '발효일자', '신규제정',
|
||||||
|
'바인더 표지', '보관번호', '보관목록', '관리부서',
|
||||||
|
'prepared by', 'format no', 'storage number'
|
||||||
|
]
|
||||||
|
|
||||||
|
if any(pattern in content_lower for pattern in irrelevant_patterns):
|
||||||
|
return False
|
||||||
|
|
||||||
|
# 키워드 매칭 확인 (완화된 버전)
|
||||||
|
keyword_matches = sum(1 for keyword in question_keywords if keyword in content_lower)
|
||||||
|
|
||||||
|
# 최소 1개 이상의 키워드가 매칭되면 관련성이 있다고 판단 (2→1로 완화)
|
||||||
|
return keyword_matches >= 1
|
||||||
|
|
||||||
|
def _verify_answer_document_relevance(self, answer: str, doc_content: str, question: str) -> bool:
|
||||||
|
"""답변과 문서 내용의 직접적 연관성 검증"""
|
||||||
|
if not answer or not doc_content:
|
||||||
|
return False
|
||||||
|
|
||||||
|
answer_lower = answer.lower()
|
||||||
|
doc_content_lower = doc_content.lower()
|
||||||
|
|
||||||
|
# 답변에서 추출한 핵심 키워드들
|
||||||
|
answer_keywords = []
|
||||||
|
|
||||||
|
# 답변에서 구체적인 정보나 절차를 추출
|
||||||
|
import re
|
||||||
|
|
||||||
|
# 숫자나 코드 패턴 (예: "DWPRND-DT-SOP-001-F07", "201609T001")
|
||||||
|
code_patterns = re.findall(r'[A-Z]{2,}[A-Z0-9-]+', answer)
|
||||||
|
answer_keywords.extend(code_patterns)
|
||||||
|
|
||||||
|
# 구체적인 절차나 단계 (예: "신청서 작성", "승인 절차")
|
||||||
|
procedure_patterns = re.findall(r'[가-힣]{2,}\s*[가-힣]{2,}', answer)
|
||||||
|
answer_keywords.extend(procedure_patterns)
|
||||||
|
|
||||||
|
# 문서 내용에서 답변의 핵심 키워드가 실제로 언급되는지 확인
|
||||||
|
relevance_score = 0
|
||||||
|
for keyword in answer_keywords:
|
||||||
|
if keyword.lower() in doc_content_lower:
|
||||||
|
relevance_score += 1
|
||||||
|
|
||||||
|
# 답변의 핵심 내용이 문서에 실제로 존재하는지 확인
|
||||||
|
answer_sentences = [s.strip() for s in answer.split('.') if len(s.strip()) > 10]
|
||||||
|
doc_sentence_matches = 0
|
||||||
|
|
||||||
|
for sentence in answer_sentences[:3]: # 답변의 처음 3개 문장만 확인
|
||||||
|
sentence_keywords = re.findall(r'[가-힣]{2,}', sentence)
|
||||||
|
if len(sentence_keywords) >= 2:
|
||||||
|
# 문장의 키워드들이 문서에 모두 있는지 확인
|
||||||
|
if all(keyword in doc_content_lower for keyword in sentence_keywords[:2]):
|
||||||
|
doc_sentence_matches += 1
|
||||||
|
|
||||||
|
# 관련성 판단: 더 관대하게 판단 (완화)
|
||||||
|
return relevance_score >= 0 or doc_sentence_matches >= 0 or len(answer_keywords) > 0
|
||||||
|
|
||||||
|
def _fallback_hybrid_search(self, question: str) -> List[Document]:
|
||||||
|
"""컨텍스트 검색 실패 시 사용할 기본 하이브리드 검색"""
|
||||||
|
logger.info(f"🔄 기본 하이브리드 검색 실행: {question}")
|
||||||
|
|
||||||
|
# 1. 벡터 검색으로 관련 문서 검색
|
||||||
|
vector_docs = self.vector_retriever.get_relevant_documents(question)
|
||||||
|
logger.info(f"📊 벡터 검색 결과: {len(vector_docs)}개 문서")
|
||||||
|
|
||||||
|
# 2. MMR 검색으로 다양성 있는 문서 검색
|
||||||
|
mmr_docs = self.mmr_retriever.get_relevant_documents(question)
|
||||||
|
logger.info(f"📊 MMR 검색 결과: {len(mmr_docs)}개 문서")
|
||||||
|
|
||||||
|
# 3. 두 검색 결과를 결합하여 중복 제거
|
||||||
|
all_docs = vector_docs + mmr_docs
|
||||||
|
unique_docs = []
|
||||||
|
seen_metadata = set()
|
||||||
|
|
||||||
|
for doc in all_docs:
|
||||||
|
doc_key = f"{doc.metadata.get('filename', '')}_{doc.metadata.get('chunk_index', 0)}"
|
||||||
|
if doc_key not in seen_metadata:
|
||||||
|
unique_docs.append(doc)
|
||||||
|
seen_metadata.add(doc_key)
|
||||||
|
|
||||||
|
logger.info(f"📊 중복 제거 후 총 {len(unique_docs)}개 문서")
|
||||||
|
return unique_docs
|
||||||
|
|
||||||
def generate_answer(self, question: str) -> Dict[str, Any]:
|
def generate_answer(self, question: str) -> Dict[str, Any]:
|
||||||
"""RAG를 통한 답변 생성"""
|
"""RAG를 통한 답변 생성"""
|
||||||
try:
|
try:
|
||||||
# RAG 체인을 사용하여 답변 생성
|
# 컨텍스트 기반 검색으로 더 정확한 문서 검색
|
||||||
|
logger.info(f"🤖 컨텍스트 기반 검색으로 문서 검색 시작: {question}")
|
||||||
|
|
||||||
|
# 1. 컨텍스트 검색 시스템 사용
|
||||||
|
if self.context_retrieval:
|
||||||
|
try:
|
||||||
|
# 컨텍스트 기반 통합 검색 (임베딩 + BM25 + Reranker)
|
||||||
|
context_results = self.context_retrieval.search(question, top_k=15)
|
||||||
|
|
||||||
|
# 컨텍스트 검색 결과를 LangChain Document 형식으로 변환
|
||||||
|
unique_docs = []
|
||||||
|
for result in context_results:
|
||||||
|
if 'document' in result:
|
||||||
|
unique_docs.append(result['document'])
|
||||||
|
|
||||||
|
logger.info(f"📊 컨텍스트 기반 검색 결과: {len(unique_docs)}개 문서")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ 컨텍스트 검색 실패, 기본 검색으로 대체: {e}")
|
||||||
|
# 컨텍스트 검색 실패 시 기본 하이브리드 검색 사용
|
||||||
|
unique_docs = self._fallback_hybrid_search(question)
|
||||||
|
else:
|
||||||
|
# 컨텍스트 검색 시스템이 없는 경우 기본 하이브리드 검색 사용
|
||||||
|
unique_docs = self._fallback_hybrid_search(question)
|
||||||
|
|
||||||
|
# 2. RAG 체인을 사용하여 답변 생성
|
||||||
if self.qa_chain:
|
if self.qa_chain:
|
||||||
logger.info(f"🤖 LLM을 사용한 RAG 답변 생성 시작: {question}")
|
logger.info(f"🤖 LLM을 사용한 RAG 답변 생성 시작: {question}")
|
||||||
result = self.qa_chain.invoke({"input": question})
|
result = self.qa_chain.invoke({"input": question})
|
||||||
|
|
||||||
# 참조 문서 정보 추출
|
# LLM이 실제로 사용한 문서 정보 추출
|
||||||
references = []
|
references = []
|
||||||
source_documents = result.get("context", [])
|
detailed_references = [] # 인라인 링크용 상세 정보
|
||||||
|
source_documents = result.get("context", []) # LLM이 실제로 사용한 문서
|
||||||
|
|
||||||
for doc in source_documents:
|
logger.info(f"📋 LLM이 실제로 사용한 문서 수: {len(source_documents)}개")
|
||||||
|
for i, doc in enumerate(source_documents):
|
||||||
|
# filename 추출 로직 개선
|
||||||
|
filename = doc.metadata.get('filename', 'Unknown') if hasattr(doc, 'metadata') and doc.metadata else 'Unknown'
|
||||||
|
if filename == 'Unknown' or not filename:
|
||||||
|
# source 필드에서 filename 추출 시도
|
||||||
|
source = doc.metadata.get('source', '') if hasattr(doc, 'metadata') and doc.metadata else ''
|
||||||
|
if source:
|
||||||
|
# UUID_filename.pdf 형태에서 filename.pdf 추출
|
||||||
|
import re
|
||||||
|
match = re.search(r'[a-f0-9-]{36}_(.+)', source)
|
||||||
|
if match:
|
||||||
|
filename = match.group(1)
|
||||||
|
else:
|
||||||
|
filename = source
|
||||||
|
logger.info(f"📋 문서 {i+1}: {filename}")
|
||||||
|
|
||||||
|
# LLM이 실제로 사용한 문서에 대해 관련성 재검증 및 필터링
|
||||||
|
logger.info(f"📋 LLM이 사용한 문서에 대해 관련성 재검증 시작: {len(source_documents)}개")
|
||||||
|
filtered_documents = self._filter_relevant_documents(source_documents, question)
|
||||||
|
logger.info(f"📋 관련성 재검증 완료: {len(source_documents)}개 → {len(filtered_documents)}개")
|
||||||
|
|
||||||
|
# 답변과 문서의 직접적 연관성 추가 검증
|
||||||
|
answer = result.get("answer", "")
|
||||||
|
final_relevant_documents = []
|
||||||
|
|
||||||
|
for doc in filtered_documents:
|
||||||
if hasattr(doc, 'metadata') and doc.metadata:
|
if hasattr(doc, 'metadata') and doc.metadata:
|
||||||
filename = doc.metadata.get('filename', 'Unknown')
|
filename = doc.metadata.get('filename', 'Unknown')
|
||||||
file_id = doc.metadata.get('file_id', 'unknown')
|
file_id = doc.metadata.get('file_id', 'unknown')
|
||||||
chunk_index = doc.metadata.get('chunk_index', 0)
|
chunk_index = doc.metadata.get('chunk_index', 0)
|
||||||
page_number = chunk_index + 1
|
page_count = doc.metadata.get('page_count', 0)
|
||||||
|
estimated_page = doc.metadata.get('estimated_page', chunk_index + 1)
|
||||||
|
|
||||||
|
# 실제 페이지 번호가 존재하는지 확인하고 범위 내에서만 참조
|
||||||
|
if page_count > 0 and estimated_page <= page_count:
|
||||||
|
page_number = estimated_page
|
||||||
|
else:
|
||||||
|
# 페이지 정보가 없거나 범위를 벗어난 경우 청크 인덱스 사용
|
||||||
|
page_number = min(chunk_index + 1, page_count) if page_count > 0 else chunk_index + 1
|
||||||
|
|
||||||
|
# 답변과 문서 내용의 직접적 연관성 추가 검증 (완화)
|
||||||
|
doc_content = doc.page_content if hasattr(doc, 'page_content') else ""
|
||||||
|
answer_doc_relevance = self._verify_answer_document_relevance(answer, doc_content, question)
|
||||||
|
|
||||||
|
# 연관성 검증을 거의 제거 (매우 관대하게 적용)
|
||||||
|
if answer_doc_relevance or len(doc_content) > 20: # 내용이 조금만 있어도 포함
|
||||||
|
final_relevant_documents.append(doc)
|
||||||
|
logger.info(f"📋 답변-문서 연관성 검증 통과: {filename} p{page_number}")
|
||||||
|
else:
|
||||||
|
logger.info(f"📋 답변-문서 연관성 검증 실패 - 제외됨: {filename} p{page_number}")
|
||||||
|
|
||||||
|
logger.info(f"📋 최종 관련성 검증 완료: {len(filtered_documents)}개 → {len(final_relevant_documents)}개")
|
||||||
|
|
||||||
|
# 최종 관련성 검증을 통과한 문서들만 참조 문서로 사용
|
||||||
|
for doc in final_relevant_documents:
|
||||||
|
if hasattr(doc, 'metadata') and doc.metadata:
|
||||||
|
# filename 추출 로직 개선
|
||||||
|
filename = doc.metadata.get('filename', 'Unknown')
|
||||||
|
if filename == 'Unknown' or not filename:
|
||||||
|
# source 필드에서 filename 추출 시도
|
||||||
|
source = doc.metadata.get('source', '')
|
||||||
|
if source:
|
||||||
|
# UUID_filename.pdf 형태에서 filename.pdf 추출
|
||||||
|
import re
|
||||||
|
match = re.search(r'[a-f0-9-]{36}_(.+)', source)
|
||||||
|
if match:
|
||||||
|
filename = match.group(1)
|
||||||
|
else:
|
||||||
|
filename = source
|
||||||
|
|
||||||
|
file_id = doc.metadata.get('file_id', 'unknown')
|
||||||
|
chunk_index = doc.metadata.get('chunk_index', 0)
|
||||||
|
page_count = doc.metadata.get('page_count', 0)
|
||||||
|
estimated_page = doc.metadata.get('estimated_page', chunk_index + 1)
|
||||||
|
|
||||||
|
# 실제 페이지 번호가 존재하는지 확인하고 범위 내에서만 참조
|
||||||
|
if page_count > 0 and estimated_page <= page_count:
|
||||||
|
page_number = estimated_page
|
||||||
|
else:
|
||||||
|
# 페이지 정보가 없거나 범위를 벗어난 경우 청크 인덱스 사용
|
||||||
|
page_number = min(chunk_index + 1, page_count) if page_count > 0 else chunk_index + 1
|
||||||
|
|
||||||
|
doc_content = doc.page_content if hasattr(doc, 'page_content') else ""
|
||||||
|
|
||||||
|
# 기존 참조 문서 형식 (하단 참조 문서 섹션용)
|
||||||
references.append(f"{filename}::{file_id} [p{page_number}]")
|
references.append(f"{filename}::{file_id} [p{page_number}]")
|
||||||
|
|
||||||
|
# 인라인 링크용 상세 정보 (최종 관련성 검증 통과한 문서만)
|
||||||
|
detailed_references.append({
|
||||||
|
"filename": filename,
|
||||||
|
"file_id": file_id,
|
||||||
|
"page_number": page_number,
|
||||||
|
"chunk_index": chunk_index,
|
||||||
|
"content_preview": doc_content[:100] + "..." if len(doc_content) > 100 else doc_content,
|
||||||
|
"full_content": doc_content, # 전체 내용 추가
|
||||||
|
"is_relevant": True # 최종 검증 통과
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.info(f"📋 최종 참조 문서에 추가: {filename} p{page_number}")
|
||||||
|
logger.info(f"📋 detailed_references 길이: {len(detailed_references)}")
|
||||||
|
logger.info(f"📋 detailed_references 내용: {detailed_references[-1]}")
|
||||||
|
|
||||||
# <think> 태그 및 영어 사고 과정 제거 (강화된 버전)
|
# <think> 태그 및 영어 사고 과정 제거 (강화된 버전)
|
||||||
answer = result.get("answer", "답변을 생성할 수 없습니다.")
|
answer = result.get("answer", "답변을 생성할 수 없습니다.")
|
||||||
@ -332,9 +1059,20 @@ class LangChainRAGService:
|
|||||||
for pattern in english_thinking_patterns:
|
for pattern in english_thinking_patterns:
|
||||||
answer = re.sub(pattern, '', answer, flags=re.MULTILINE | re.IGNORECASE).strip()
|
answer = re.sub(pattern, '', answer, flags=re.MULTILINE | re.IGNORECASE).strip()
|
||||||
|
|
||||||
|
# 마크다운 링크 제거 (예: [문서명](https://example.com/attachment1))
|
||||||
|
answer = re.sub(r'\[([^\]]+)\]\(https?://[^\)]+\)', r'[\1]', answer)
|
||||||
|
answer = re.sub(r'\[([^\]]+)\]\([^\)]*example\.com[^\)]*\)', r'[\1]', answer)
|
||||||
|
answer = re.sub(r'\[([^\]]+)\]\([^\)]*attachment\d+[^\)]*\)', r'[\1]', answer)
|
||||||
|
|
||||||
|
# 괄호 내 문서 ID 제거 (예: [문서명](RSC-PM-SOP-0001-F01))
|
||||||
|
answer = re.sub(r'\[([^\]]+)\]\([A-Z0-9-]+\)', r'[\1]', answer)
|
||||||
|
answer = re.sub(r'\[([^\]]+)\]\([A-Z0-9-]+-[A-Z0-9-]+\)', r'[\1]', answer)
|
||||||
|
|
||||||
|
|
||||||
response = {
|
response = {
|
||||||
"answer": answer,
|
"answer": answer,
|
||||||
"references": references,
|
"references": references,
|
||||||
|
"detailed_references": detailed_references, # 인라인 링크용 상세 정보
|
||||||
"source_documents": source_documents
|
"source_documents": source_documents
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -347,8 +1085,27 @@ class LangChainRAGService:
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"❌ RAG 답변 생성 실패: {e}")
|
logger.error(f"❌ RAG 답변 생성 실패: {e}")
|
||||||
# 오류 시 폴백 답변 생성
|
|
||||||
return self._generate_fallback_answer(question)
|
# vectordb 관련 오류인지 확인하고 사용자 친화적 메시지로 변환
|
||||||
|
error_message = str(e).lower()
|
||||||
|
if any(keyword in error_message for keyword in [
|
||||||
|
"readonly database",
|
||||||
|
"code: 1032",
|
||||||
|
"chromadb",
|
||||||
|
"vectorstore",
|
||||||
|
"collection",
|
||||||
|
"database error"
|
||||||
|
]):
|
||||||
|
logger.error(f"🔍 벡터DB 관련 오류 감지: {e}")
|
||||||
|
return {
|
||||||
|
"answer": "벡터DB에 문제가 있습니다. 관리자에게 문의해주세요.",
|
||||||
|
"references": [],
|
||||||
|
"source_documents": [],
|
||||||
|
"detailed_references": []
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
# 오류 시 폴백 답변 생성
|
||||||
|
return self._generate_fallback_answer(question)
|
||||||
|
|
||||||
def _generate_fallback_answer(self, question: str) -> Dict[str, Any]:
|
def _generate_fallback_answer(self, question: str) -> Dict[str, Any]:
|
||||||
"""폴백 답변 생성 (LLM 없이)"""
|
"""폴백 답변 생성 (LLM 없이)"""
|
||||||
@ -393,11 +1150,29 @@ class LangChainRAGService:
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"❌ 폴백 답변 생성 실패: {e}")
|
logger.error(f"❌ 폴백 답변 생성 실패: {e}")
|
||||||
return {
|
|
||||||
"answer": "죄송합니다. 현재 시스템 오류로 인해 답변을 생성할 수 없습니다. 잠시 후 다시 시도해주세요.",
|
# vectordb 관련 오류인지 확인하고 사용자 친화적 메시지로 변환
|
||||||
"references": [],
|
error_message = str(e).lower()
|
||||||
"source_documents": []
|
if any(keyword in error_message for keyword in [
|
||||||
}
|
"readonly database",
|
||||||
|
"code: 1032",
|
||||||
|
"chromadb",
|
||||||
|
"vectorstore",
|
||||||
|
"collection",
|
||||||
|
"database error"
|
||||||
|
]):
|
||||||
|
logger.error(f"🔍 벡터DB 관련 오류 감지: {e}")
|
||||||
|
return {
|
||||||
|
"answer": "벡터DB에 문제가 있습니다. 관리자에게 문의해주세요.",
|
||||||
|
"references": [],
|
||||||
|
"source_documents": []
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
return {
|
||||||
|
"answer": "죄송합니다. 현재 시스템 오류로 인해 답변을 생성할 수 없습니다. 잠시 후 다시 시도해주세요.",
|
||||||
|
"references": [],
|
||||||
|
"source_documents": []
|
||||||
|
}
|
||||||
|
|
||||||
def get_collection_info(self) -> Dict[str, Any]:
|
def get_collection_info(self) -> Dict[str, Any]:
|
||||||
"""컬렉션 정보 조회"""
|
"""컬렉션 정보 조회"""
|
||||||
@ -421,12 +1196,16 @@ class LangChainRAGService:
|
|||||||
try:
|
try:
|
||||||
# 메타데이터로 필터링하여 삭제
|
# 메타데이터로 필터링하여 삭제
|
||||||
collection = self.vectorstore._collection
|
collection = self.vectorstore._collection
|
||||||
collection.delete(where={"filename": filename})
|
|
||||||
|
# source 필드에서 파일명 추출하여 삭제
|
||||||
|
# source는 "UUID_파일명.pdf" 형태
|
||||||
|
collection.delete(where={"source": {"$contains": filename}})
|
||||||
logger.info(f"✅ {filename} 관련 문서 삭제 완료")
|
logger.info(f"✅ {filename} 관련 문서 삭제 완료")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"❌ 문서 삭제 실패: {e}")
|
logger.error(f"❌ 문서 삭제 실패: {e}")
|
||||||
raise
|
# 삭제 실패해도 계속 진행 (파일이 이미 삭제되었을 수 있음)
|
||||||
|
logger.warning(f"⚠️ 문서 삭제 실패했지만 계속 진행: {e}")
|
||||||
|
|
||||||
def cleanup_database_by_filename(self, filename: str):
|
def cleanup_database_by_filename(self, filename: str):
|
||||||
"""데이터베이스에서 파일 관련 데이터 정리"""
|
"""데이터베이스에서 파일 관련 데이터 정리"""
|
||||||
|
|||||||
@ -82,7 +82,11 @@ function AppContent() {
|
|||||||
|
|
||||||
{/* 메인 컨텐츠 - 전체 화면 챗봇 */}
|
{/* 메인 컨텐츠 - 전체 화면 챗봇 */}
|
||||||
<main className="flex-1 flex flex-col">
|
<main className="flex-1 flex flex-col">
|
||||||
<ChatInterface />
|
<ChatInterface
|
||||||
|
showLogin={showLogin}
|
||||||
|
showFileUpload={showFileUpload}
|
||||||
|
showPDFViewer={showPDFViewer}
|
||||||
|
/>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{/* 모달들 */}
|
{/* 모달들 */}
|
||||||
|
|||||||
@ -5,7 +5,17 @@ import MessageBubble from './MessageBubble';
|
|||||||
import TypingIndicator from './TypingIndicator';
|
import TypingIndicator from './TypingIndicator';
|
||||||
import { Send } from 'lucide-react';
|
import { Send } from 'lucide-react';
|
||||||
|
|
||||||
const ChatInterface: React.FC = () => {
|
interface ChatInterfaceProps {
|
||||||
|
showLogin?: boolean;
|
||||||
|
showFileUpload?: boolean;
|
||||||
|
showPDFViewer?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChatInterface: React.FC<ChatInterfaceProps> = ({
|
||||||
|
showLogin = false,
|
||||||
|
showFileUpload = false,
|
||||||
|
showPDFViewer = false
|
||||||
|
}) => {
|
||||||
const { messages, addMessage, isLoading, setIsLoading } = useChat();
|
const { messages, addMessage, isLoading, setIsLoading } = useChat();
|
||||||
const [inputMessage, setInputMessage] = useState('');
|
const [inputMessage, setInputMessage] = useState('');
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
@ -19,6 +29,39 @@ const ChatInterface: React.FC = () => {
|
|||||||
scrollToBottom();
|
scrollToBottom();
|
||||||
}, [messages]);
|
}, [messages]);
|
||||||
|
|
||||||
|
// 컴포넌트가 마운트되거나 활성화될 때 입력창에 포커스
|
||||||
|
useEffect(() => {
|
||||||
|
const focusInput = () => {
|
||||||
|
if (inputRef.current && !isLoading) {
|
||||||
|
inputRef.current.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 컴포넌트 마운트 시 포커스
|
||||||
|
focusInput();
|
||||||
|
|
||||||
|
// 다른 모달이 닫힌 후 포커스 (약간의 지연을 두어 모달 애니메이션 완료 후)
|
||||||
|
const timer = setTimeout(focusInput, 300);
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [isLoading]);
|
||||||
|
|
||||||
|
// 모달 상태 변화 감지하여 입력창에 포커스
|
||||||
|
useEffect(() => {
|
||||||
|
const focusInput = () => {
|
||||||
|
if (inputRef.current && !isLoading) {
|
||||||
|
inputRef.current.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 모든 모달이 닫혔을 때 입력창에 포커스
|
||||||
|
if (!showLogin && !showFileUpload && !showPDFViewer) {
|
||||||
|
// 모달 애니메이션 완료 후 포커스
|
||||||
|
const timer = setTimeout(focusInput, 500);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [showLogin, showFileUpload, showPDFViewer, isLoading]);
|
||||||
|
|
||||||
const handleSendMessage = async () => {
|
const handleSendMessage = async () => {
|
||||||
if (!inputMessage.trim() || isLoading) return;
|
if (!inputMessage.trim() || isLoading) return;
|
||||||
|
|
||||||
@ -58,10 +101,15 @@ const ChatInterface: React.FC = () => {
|
|||||||
console.log('📋 응답 내용:', data.response);
|
console.log('📋 응답 내용:', data.response);
|
||||||
console.log('📋 참조 문서:', data.sources);
|
console.log('📋 참조 문서:', data.sources);
|
||||||
|
|
||||||
|
console.log('🔍 API 응답 전체:', data);
|
||||||
|
console.log('🔍 detailed_references:', data.detailed_references);
|
||||||
|
console.log('🔍 detailed_references 길이:', data.detailed_references?.length);
|
||||||
|
|
||||||
addMessage({
|
addMessage({
|
||||||
content: data.response,
|
content: data.response,
|
||||||
isUser: false,
|
isUser: false,
|
||||||
sources: data.sources,
|
sources: data.sources,
|
||||||
|
detailedReferences: data.detailed_references,
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('💬 챗봇 메시지 추가 완료');
|
console.log('💬 챗봇 메시지 추가 완료');
|
||||||
@ -85,6 +133,13 @@ const ChatInterface: React.FC = () => {
|
|||||||
} finally {
|
} finally {
|
||||||
console.log('🏁 챗봇 요청 완료');
|
console.log('🏁 챗봇 요청 완료');
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
|
|
||||||
|
// 응답 완료 후 입력창에 포커스
|
||||||
|
setTimeout(() => {
|
||||||
|
if (inputRef.current) {
|
||||||
|
inputRef.current.focus();
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useRef } from 'react';
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { useFiles } from '../contexts/FileContext';
|
import { useFiles } from '../contexts/FileContext';
|
||||||
import { X, Upload, Trash2, Search } from 'lucide-react';
|
import { X, Upload, Trash2, Search } from 'lucide-react';
|
||||||
@ -9,7 +9,7 @@ interface FileUploadModalProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const FileUploadModal: React.FC<FileUploadModalProps> = ({ onClose, onPDFView }) => {
|
const FileUploadModal: React.FC<FileUploadModalProps> = ({ onClose, onPDFView }) => {
|
||||||
const { files, uploadFile, deleteFile, refreshFiles, searchFiles, isLoading } = useFiles();
|
const { files, uploadFile, deleteFile, refreshFiles, searchFiles, isLoading, isFileLoading } = useFiles();
|
||||||
const [dragActive, setDragActive] = useState(false);
|
const [dragActive, setDragActive] = useState(false);
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
const [uploadStatus, setUploadStatus] = useState<'idle' | 'success' | 'error'>('idle');
|
const [uploadStatus, setUploadStatus] = useState<'idle' | 'success' | 'error'>('idle');
|
||||||
@ -31,12 +31,108 @@ const FileUploadModal: React.FC<FileUploadModalProps> = ({ onClose, onPDFView })
|
|||||||
currentFile: string;
|
currentFile: string;
|
||||||
progress: number;
|
progress: number;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
const [isUploadCancelled, setIsUploadCancelled] = useState(false);
|
||||||
|
const [uploadedFileIds, setUploadedFileIds] = useState<string[]>([]);
|
||||||
|
|
||||||
|
// 모달 자체의 로딩 상태 (FileContext의 isLoading과 독립적)
|
||||||
|
const [modalLoading, setModalLoading] = useState(false);
|
||||||
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const abortControllerRef = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
|
// 모달이 열릴 때마다 파일 목록 새로고침 (독립적으로 실행)
|
||||||
|
useEffect(() => {
|
||||||
|
console.log('📁 FileUploadModal 열림 - 파일 목록 새로고침 시작');
|
||||||
|
console.log('📁 현재 files 상태:', files);
|
||||||
|
console.log('📁 현재 isLoading 상태:', isLoading);
|
||||||
|
|
||||||
|
const loadFiles = async () => {
|
||||||
|
setModalLoading(true);
|
||||||
|
try {
|
||||||
|
await refreshFiles();
|
||||||
|
console.log('📁 FileUploadModal - 파일 목록 새로고침 완료');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('📁 FileUploadModal - 파일 목록 새로고침 실패:', error);
|
||||||
|
} finally {
|
||||||
|
setModalLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadFiles();
|
||||||
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
// ESC 키로 업로드 중단 및 모달 닫기
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
if (isUploading) {
|
||||||
|
// 업로드 중이면 업로드 중단
|
||||||
|
handleCancelUpload();
|
||||||
|
} else {
|
||||||
|
// 업로드 중이 아니면 모달 닫기
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('keydown', handleKeyDown);
|
||||||
|
};
|
||||||
|
}, [isUploading, onClose]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
// 업로드 중단 핸들러
|
||||||
|
const handleCancelUpload = async () => {
|
||||||
|
console.log('🛑 업로드 중단 요청');
|
||||||
|
|
||||||
|
// 즉시 중단 상태 설정
|
||||||
|
setIsUploadCancelled(true);
|
||||||
|
|
||||||
|
// AbortController로 진행 중인 모든 요청 중단
|
||||||
|
if (abortControllerRef.current) {
|
||||||
|
console.log('🛑 AbortController로 진행 중인 요청들 중단');
|
||||||
|
abortControllerRef.current.abort();
|
||||||
|
abortControllerRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 업로드된 파일들 롤백
|
||||||
|
if (uploadedFileIds.length > 0) {
|
||||||
|
console.log('🛑 업로드된 파일들 롤백 시작:', uploadedFileIds);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 각 파일에 대해 삭제 요청
|
||||||
|
for (const fileId of uploadedFileIds) {
|
||||||
|
await deleteFile(fileId);
|
||||||
|
console.log(`🛑 파일 삭제 완료: ${fileId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 업로드된 파일 ID 목록 초기화
|
||||||
|
setUploadedFileIds([]);
|
||||||
|
console.log('🛑 모든 업로드된 파일 롤백 완료');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('🛑 파일 롤백 중 오류:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 상태 초기화
|
||||||
|
setIsUploading(false);
|
||||||
|
setUploadProgress(null);
|
||||||
|
|
||||||
|
// 파일 목록 새로고침
|
||||||
|
await refreshFiles();
|
||||||
|
|
||||||
|
console.log('🛑 업로드 중단 완료');
|
||||||
|
};
|
||||||
|
|
||||||
// 순차적 업로드 함수
|
// 순차적 업로드 함수
|
||||||
const uploadFilesSequentially = async (files: File[]) => {
|
const uploadFilesSequentially = async (files: File[]) => {
|
||||||
|
setIsUploadCancelled(false);
|
||||||
setIsUploading(true);
|
setIsUploading(true);
|
||||||
|
setUploadedFileIds([]); // 업로드된 파일 ID 목록 초기화
|
||||||
|
|
||||||
|
// AbortController 생성
|
||||||
|
abortControllerRef.current = new AbortController();
|
||||||
|
|
||||||
setUploadProgress({
|
setUploadProgress({
|
||||||
current: 0,
|
current: 0,
|
||||||
total: files.length,
|
total: files.length,
|
||||||
@ -48,33 +144,63 @@ const FileUploadModal: React.FC<FileUploadModalProps> = ({ onClose, onPDFView })
|
|||||||
let errorCount = 0;
|
let errorCount = 0;
|
||||||
|
|
||||||
for (let i = 0; i < files.length; i++) {
|
for (let i = 0; i < files.length; i++) {
|
||||||
|
// 업로드 중단 체크 (여러 조건 확인)
|
||||||
|
if (isUploadCancelled || !abortControllerRef.current || abortControllerRef.current.signal.aborted) {
|
||||||
|
console.log('🛑 업로드 중단됨 - 루프 종료');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
const file = files[i];
|
const file = files[i];
|
||||||
|
|
||||||
setUploadProgress({
|
setUploadProgress({
|
||||||
current: i + 1,
|
current: i + 1,
|
||||||
total: files.length,
|
total: files.length,
|
||||||
currentFile: file.name,
|
currentFile: file.name,
|
||||||
progress: 0
|
progress: (i / files.length) * 100
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 개별 파일 업로드
|
// 업로드 전 중단 체크
|
||||||
const result = await uploadFile(file);
|
if (isUploadCancelled || !abortControllerRef.current || abortControllerRef.current.signal.aborted) {
|
||||||
|
console.log('🛑 파일 업로드 전 중단 체크 - 업로드 건너뜀');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
if (result) {
|
// 개별 파일 업로드 (AbortSignal 전달)
|
||||||
|
const fileId = await uploadFile(file, abortControllerRef.current?.signal);
|
||||||
|
|
||||||
|
if (fileId) {
|
||||||
successCount++;
|
successCount++;
|
||||||
|
// 업로드된 파일 ID를 목록에 추가
|
||||||
|
setUploadedFileIds(prev => [...prev, fileId]);
|
||||||
|
console.log(`📋 업로드된 파일 ID 추가: ${fileId}`);
|
||||||
} else {
|
} else {
|
||||||
errorCount++;
|
errorCount++;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 진행률 업데이트
|
// 파일 완료 시 진행률 업데이트
|
||||||
setUploadProgress(prev => prev ? {
|
setUploadProgress(prev => prev ? {
|
||||||
...prev,
|
...prev,
|
||||||
progress: ((i + 1) / files.length) * 100
|
progress: ((i + 1) / files.length) * 100
|
||||||
} : null);
|
} : null);
|
||||||
|
|
||||||
// 파일 간 짧은 지연 (UI 업데이트를 위해)
|
// 파일 간 짧은 지연 (UI 업데이트를 위해) - 중단 체크 포함
|
||||||
await new Promise(resolve => setTimeout(resolve, 500));
|
await new Promise<void>(resolve => {
|
||||||
|
const timeout = setTimeout(() => resolve(), 500);
|
||||||
|
// 중단 체크를 위한 짧은 간격으로 체크
|
||||||
|
const checkInterval = setInterval(() => {
|
||||||
|
if (isUploadCancelled || !abortControllerRef.current || abortControllerRef.current.signal.aborted) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
clearInterval(checkInterval);
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
// 500ms 후 정리
|
||||||
|
setTimeout(() => {
|
||||||
|
clearInterval(checkInterval);
|
||||||
|
}, 500);
|
||||||
|
});
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`파일 업로드 실패: ${file.name}`, error);
|
console.error(`파일 업로드 실패: ${file.name}`, error);
|
||||||
@ -82,19 +208,30 @@ const FileUploadModal: React.FC<FileUploadModalProps> = ({ onClose, onPDFView })
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 업로드 완료 후 상태 정리
|
||||||
setIsUploading(false);
|
setIsUploading(false);
|
||||||
setUploadProgress(null);
|
setUploadProgress(null);
|
||||||
|
|
||||||
|
// AbortController 정리
|
||||||
|
abortControllerRef.current = null;
|
||||||
|
|
||||||
// 결과 메시지 표시
|
// 중단된 경우와 완료된 경우 구분
|
||||||
if (successCount > 0 && errorCount === 0) {
|
if (isUploadCancelled) {
|
||||||
setUploadStatus('success');
|
|
||||||
setUploadMessage(`${successCount}개 파일이 성공적으로 업로드되었습니다.`);
|
|
||||||
} else if (successCount > 0 && errorCount > 0) {
|
|
||||||
setUploadStatus('error');
|
setUploadStatus('error');
|
||||||
setUploadMessage(`${successCount}개 성공, ${errorCount}개 실패`);
|
setUploadMessage(`업로드가 중단되었습니다. (${successCount}개 완료)`);
|
||||||
|
console.log('🛑 업로드 중단으로 인한 완료');
|
||||||
} else {
|
} else {
|
||||||
setUploadStatus('error');
|
// 결과 메시지 표시
|
||||||
setUploadMessage('모든 파일 업로드에 실패했습니다.');
|
if (successCount > 0 && errorCount === 0) {
|
||||||
|
setUploadStatus('success');
|
||||||
|
setUploadMessage(`${successCount}개 파일이 성공적으로 업로드되었습니다.`);
|
||||||
|
} else if (successCount > 0 && errorCount > 0) {
|
||||||
|
setUploadStatus('error');
|
||||||
|
setUploadMessage(`${successCount}개 성공, ${errorCount}개 실패`);
|
||||||
|
} else {
|
||||||
|
setUploadStatus('error');
|
||||||
|
setUploadMessage('모든 파일 업로드에 실패했습니다.');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setTimeout(() => setUploadStatus('idle'), 3000);
|
setTimeout(() => setUploadStatus('idle'), 3000);
|
||||||
@ -286,7 +423,12 @@ const FileUploadModal: React.FC<FileUploadModalProps> = ({ onClose, onPDFView })
|
|||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
exit={{ opacity: 0 }}
|
exit={{ opacity: 0 }}
|
||||||
className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4"
|
className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4"
|
||||||
onClick={onClose}
|
onClick={() => {
|
||||||
|
if (isUploading) {
|
||||||
|
handleCancelUpload();
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, scale: 0.9, y: 20 }}
|
initial={{ opacity: 0, scale: 0.9, y: 20 }}
|
||||||
@ -299,7 +441,12 @@ const FileUploadModal: React.FC<FileUploadModalProps> = ({ onClose, onPDFView })
|
|||||||
<div className="flex items-center justify-between p-6 border-b border-gray-200">
|
<div className="flex items-center justify-between p-6 border-b border-gray-200">
|
||||||
<h2 className="text-xl font-semibold text-gray-800">PDF 업로드 및 관리</h2>
|
<h2 className="text-xl font-semibold text-gray-800">PDF 업로드 및 관리</h2>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={() => {
|
||||||
|
if (isUploading) {
|
||||||
|
handleCancelUpload();
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
||||||
>
|
>
|
||||||
<X className="w-5 h-5 text-gray-500" />
|
<X className="w-5 h-5 text-gray-500" />
|
||||||
@ -310,21 +457,38 @@ const FileUploadModal: React.FC<FileUploadModalProps> = ({ onClose, onPDFView })
|
|||||||
{/* 업로드 진행 상태 */}
|
{/* 업로드 진행 상태 */}
|
||||||
{uploadProgress && (
|
{uploadProgress && (
|
||||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||||
<div className="flex items-center space-x-3 mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-blue-500"></div>
|
<div className="flex items-center space-x-3">
|
||||||
<span className="text-blue-700 font-medium">
|
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-blue-500"></div>
|
||||||
파일 업로드 중입니다... ({uploadProgress.current}/{uploadProgress.total})
|
<span className="text-blue-700 font-medium">
|
||||||
</span>
|
파일 업로드 중입니다... ({uploadProgress.current}/{uploadProgress.total})
|
||||||
</div>
|
</span>
|
||||||
<div className="text-sm text-blue-600 mb-2">
|
</div>
|
||||||
현재 파일: {uploadProgress.currentFile}
|
<div className="flex items-center space-x-3">
|
||||||
</div>
|
<div className="text-sm text-blue-600">
|
||||||
<div className="w-full bg-blue-200 rounded-full h-2">
|
{Math.round(uploadProgress.progress)}% 완료
|
||||||
<div
|
</div>
|
||||||
className="bg-blue-600 h-2 rounded-full transition-all duration-300"
|
</div>
|
||||||
style={{ width: `${uploadProgress.progress}%` }}
|
</div>
|
||||||
></div>
|
|
||||||
</div>
|
{/* 전체 진행률 */}
|
||||||
|
<div className="mb-3">
|
||||||
|
<div className="flex justify-between items-center mb-1">
|
||||||
|
<span className="text-sm text-blue-600 font-medium">전체 진행률</span>
|
||||||
|
<span className="text-sm text-blue-600">{Math.round(uploadProgress.progress)}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full bg-blue-200 rounded-full h-3">
|
||||||
|
<div
|
||||||
|
className="bg-blue-600 h-3 rounded-full transition-all duration-500 ease-out"
|
||||||
|
style={{ width: `${uploadProgress.progress}%` }}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 현재 파일 정보 */}
|
||||||
|
<div className="text-sm text-blue-600 mb-2">
|
||||||
|
현재 파일: {uploadProgress.currentFile}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -456,7 +620,18 @@ const FileUploadModal: React.FC<FileUploadModalProps> = ({ onClose, onPDFView })
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="max-h-64 overflow-y-auto">
|
<div className="max-h-64 overflow-y-auto">
|
||||||
{filteredFiles.length === 0 ? (
|
{(() => {
|
||||||
|
console.log('📁 파일 목록 렌더링 상태:', { isLoading, isFileLoading, modalLoading, filteredFilesLength: filteredFiles.length, filesLength: files.length });
|
||||||
|
return null;
|
||||||
|
})()}
|
||||||
|
{(isFileLoading || modalLoading) ? (
|
||||||
|
<div className="text-center py-12 text-gray-500">
|
||||||
|
<div className="flex items-center justify-center space-x-3">
|
||||||
|
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-500"></div>
|
||||||
|
<span className="text-lg font-medium">파일 목록을 불러오는 중...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : filteredFiles.length === 0 ? (
|
||||||
<div className="text-center py-12 text-gray-500">
|
<div className="text-center py-12 text-gray-500">
|
||||||
{lastSearchTerm ? (
|
{lastSearchTerm ? (
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
import { X, Lock, User } from 'lucide-react';
|
import { X, Lock, User } from 'lucide-react';
|
||||||
@ -15,6 +15,20 @@ const LoginModal: React.FC<LoginModalProps> = ({ onClose, onSuccess }) => {
|
|||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
// ESC 키로 모달 닫기
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('keydown', handleKeyDown);
|
||||||
|
};
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
@ -73,37 +87,39 @@ const LoginModal: React.FC<LoginModalProps> = ({ onClose, onSuccess }) => {
|
|||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
<label className="block text-sm font-medium text-gray-700 mb-3">
|
||||||
아이디
|
아이디
|
||||||
</label>
|
</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<User className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" />
|
<User className="absolute left-4 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={username}
|
value={username}
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
className="w-full pl-10 pr-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none transition-all"
|
className="w-full pl-12 pr-4 py-4 border-2 border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all text-gray-900 bg-white"
|
||||||
placeholder="아이디를 입력하세요"
|
placeholder="아이디를 입력하세요"
|
||||||
required
|
required
|
||||||
|
style={{ minHeight: '48px' }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
<label className="block text-sm font-medium text-gray-700 mb-3">
|
||||||
비밀번호
|
비밀번호
|
||||||
</label>
|
</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Lock className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" />
|
<Lock className="absolute left-4 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" />
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
className="w-full pl-10 pr-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none transition-all"
|
className="w-full pl-12 pr-4 py-4 border-2 border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all text-gray-900 bg-white"
|
||||||
placeholder="비밀번호를 입력하세요"
|
placeholder="비밀번호를 입력하세요"
|
||||||
required
|
required
|
||||||
|
style={{ minHeight: '48px' }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { User, FileText } from 'lucide-react';
|
import { User, FileText } from 'lucide-react';
|
||||||
import { Message } from '../contexts/ChatContext';
|
import { Message, ReferenceInfo } from '../contexts/ChatContext';
|
||||||
import PDFViewer from './PDFViewer';
|
import PDFViewer from './PDFViewer';
|
||||||
import SimpleMarkdownRenderer from './SimpleMarkdownRenderer';
|
import SimpleMarkdownRenderer from './SimpleMarkdownRenderer';
|
||||||
|
|
||||||
@ -71,6 +71,15 @@ const MessageBubble: React.FC<MessageBubbleProps> = ({ message }) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 인라인 링크 클릭 핸들러
|
||||||
|
const handleInlineReferenceClick = (fileId: string, pageNumber: number, filename: string) => {
|
||||||
|
setSelectedPage({
|
||||||
|
fileId: fileId,
|
||||||
|
filename: filename,
|
||||||
|
pageNumber: pageNumber
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className={`flex ${isUser ? 'justify-end' : 'justify-start'} mb-6`}>
|
<div className={`flex ${isUser ? 'justify-end' : 'justify-start'} mb-6`}>
|
||||||
@ -113,7 +122,15 @@ const MessageBubble: React.FC<MessageBubbleProps> = ({ message }) => {
|
|||||||
{message.content}
|
{message.content}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<SimpleMarkdownRenderer content={message.content || ''} />
|
<>
|
||||||
|
{console.log('🔍 MessageBubble - message.detailedReferences:', message.detailedReferences)}
|
||||||
|
{console.log('🔍 MessageBubble - message.detailedReferences 길이:', message.detailedReferences?.length)}
|
||||||
|
<SimpleMarkdownRenderer
|
||||||
|
content={message.content || ''}
|
||||||
|
detailedReferences={message.detailedReferences}
|
||||||
|
onReferenceClick={handleInlineReferenceClick}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 소스 정보 */}
|
{/* 소스 정보 */}
|
||||||
|
|||||||
@ -31,8 +31,14 @@ const PDFViewer: React.FC<PDFViewerProps> = ({ fileId, filename, pageNumber, onC
|
|||||||
|
|
||||||
// PDF URL 생성
|
// PDF URL 생성
|
||||||
const url = `http://localhost:8000/pdf/${fileId}/view`;
|
const url = `http://localhost:8000/pdf/${fileId}/view`;
|
||||||
|
console.log('📄 PDF 로드 시작:', { fileId, filename, pageNumber, url });
|
||||||
setPdfUrl(url);
|
setPdfUrl(url);
|
||||||
setIsLoading(false);
|
|
||||||
|
// PDF 로드 완료를 위해 약간의 지연
|
||||||
|
setTimeout(() => {
|
||||||
|
setIsLoading(false);
|
||||||
|
console.log('📄 PDF 로드 완료:', { fileId, filename, pageNumber });
|
||||||
|
}, 100);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('PDF 로드 오류:', err);
|
console.error('PDF 로드 오류:', err);
|
||||||
setError('PDF를 불러올 수 없습니다.');
|
setError('PDF를 불러올 수 없습니다.');
|
||||||
@ -41,12 +47,26 @@ const PDFViewer: React.FC<PDFViewerProps> = ({ fileId, filename, pageNumber, onC
|
|||||||
};
|
};
|
||||||
|
|
||||||
loadPDF();
|
loadPDF();
|
||||||
}, [fileId]);
|
}, [fileId, filename, pageNumber]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setCurrentPage(pageNumber);
|
setCurrentPage(pageNumber);
|
||||||
}, [pageNumber]);
|
}, [pageNumber]);
|
||||||
|
|
||||||
|
// ESC 키로 PDF 뷰어 닫기
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('keydown', handleKeyDown);
|
||||||
|
};
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
const onDocumentLoadSuccess = useCallback(({ numPages }: { numPages: number }) => {
|
const onDocumentLoadSuccess = useCallback(({ numPages }: { numPages: number }) => {
|
||||||
setNumPages(numPages);
|
setNumPages(numPages);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
|
|||||||
@ -1,11 +1,22 @@
|
|||||||
import React, { createContext, useContext, useState } from 'react';
|
import React, { createContext, useContext, useState } from 'react';
|
||||||
|
|
||||||
|
export interface ReferenceInfo {
|
||||||
|
filename: string;
|
||||||
|
file_id: string;
|
||||||
|
page_number: number;
|
||||||
|
chunk_index: number;
|
||||||
|
content_preview: string;
|
||||||
|
full_content?: string;
|
||||||
|
is_relevant?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Message {
|
export interface Message {
|
||||||
id: string;
|
id: string;
|
||||||
content: string;
|
content: string;
|
||||||
isUser: boolean;
|
isUser: boolean;
|
||||||
timestamp: Date;
|
timestamp: Date;
|
||||||
sources?: string[];
|
sources?: string[];
|
||||||
|
detailedReferences?: ReferenceInfo[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ChatContextType {
|
interface ChatContextType {
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import React, { createContext, useContext, useState, useEffect } from 'react';
|
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
|
||||||
|
|
||||||
export interface FileInfo {
|
export interface FileInfo {
|
||||||
id: string;
|
id: string;
|
||||||
@ -10,12 +10,13 @@ export interface FileInfo {
|
|||||||
|
|
||||||
interface FileContextType {
|
interface FileContextType {
|
||||||
files: FileInfo[];
|
files: FileInfo[];
|
||||||
uploadFile: (file: File) => Promise<boolean>;
|
uploadFile: (file: File, signal?: AbortSignal) => Promise<string | null>;
|
||||||
uploadMultipleFiles: (files: FileList) => Promise<{success: number, error: number, results: any[]}>;
|
uploadMultipleFiles: (files: FileList) => Promise<{success: number, error: number, results: any[]}>;
|
||||||
deleteFile: (fileId: string) => Promise<boolean>;
|
deleteFile: (fileId: string) => Promise<boolean>;
|
||||||
refreshFiles: () => Promise<void>;
|
refreshFiles: () => Promise<void>;
|
||||||
searchFiles: (searchTerm: string) => Promise<void>;
|
searchFiles: (searchTerm: string) => Promise<void>;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
|
isFileLoading: boolean; // 파일 관련 작업만을 위한 독립적인 로딩 상태
|
||||||
}
|
}
|
||||||
|
|
||||||
const FileContext = createContext<FileContextType | undefined>(undefined);
|
const FileContext = createContext<FileContextType | undefined>(undefined);
|
||||||
@ -31,9 +32,11 @@ export const useFiles = () => {
|
|||||||
export const FileProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
export const FileProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
const [files, setFiles] = useState<FileInfo[]>([]);
|
const [files, setFiles] = useState<FileInfo[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [isFileLoading, setIsFileLoading] = useState(false); // 파일 관련 작업만을 위한 독립적인 로딩 상태
|
||||||
|
|
||||||
const fetchFiles = async (searchTerm?: string) => {
|
const fetchFiles = useCallback(async (searchTerm?: string) => {
|
||||||
try {
|
try {
|
||||||
|
setIsFileLoading(true); // 파일 관련 작업만을 위한 독립적인 로딩 상태 사용
|
||||||
console.log('📁 파일 목록 조회 시작');
|
console.log('📁 파일 목록 조회 시작');
|
||||||
|
|
||||||
let url = 'http://localhost:8000/files';
|
let url = 'http://localhost:8000/files';
|
||||||
@ -54,7 +57,14 @@ export const FileProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
|||||||
// 인증 없이 요청 (파일 목록은 누구나 조회 가능)
|
// 인증 없이 요청 (파일 목록은 누구나 조회 가능)
|
||||||
console.log('📋 인증 없이 파일 목록 요청합니다.');
|
console.log('📋 인증 없이 파일 목록 요청합니다.');
|
||||||
|
|
||||||
const response = await fetch(url);
|
const response = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
// 타임아웃 설정 (10초)
|
||||||
|
signal: AbortSignal.timeout(10000)
|
||||||
|
});
|
||||||
|
|
||||||
console.log(`📥 응답 받음: ${response.status} ${response.statusText}`);
|
console.log(`📥 응답 받음: ${response.status} ${response.statusText}`);
|
||||||
|
|
||||||
@ -62,29 +72,47 @@ export const FileProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
|||||||
console.log('✅ 파일 조회 성공');
|
console.log('✅ 파일 조회 성공');
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
const filesArray = data.files || []; // Extract files array
|
const filesArray = data.files || []; // Extract files array
|
||||||
setFiles(filesArray);
|
|
||||||
console.log(`📁 파일 조회 완료: ${filesArray.length}개 (검색어: ${searchTerm || '전체'})`);
|
// 파일 정보 형식 정규화
|
||||||
console.log(`📋 반환된 파일들:`, filesArray.map((f: FileInfo) => f.filename));
|
const normalizedFiles = filesArray.map((file: any) => ({
|
||||||
|
id: file.id?.toString() || '',
|
||||||
|
filename: file.filename || '',
|
||||||
|
upload_date: file.upload_time || file.upload_date || '',
|
||||||
|
file_type: file.file_type || 'PDF',
|
||||||
|
status: file.status || '완료'
|
||||||
|
}));
|
||||||
|
|
||||||
|
setFiles(normalizedFiles);
|
||||||
|
console.log(`📁 파일 조회 완료: ${normalizedFiles.length}개 (검색어: ${searchTerm || '전체'})`);
|
||||||
|
console.log(`📋 반환된 파일들:`, normalizedFiles.map((f: FileInfo) => f.filename));
|
||||||
} else {
|
} else {
|
||||||
console.error('❌ 파일 조회 실패');
|
console.error('❌ 파일 조회 실패');
|
||||||
console.error(`📋 상태 코드: ${response.status} ${response.statusText}`);
|
console.error(`📋 상태 코드: ${response.status} ${response.statusText}`);
|
||||||
const errorText = await response.text();
|
const errorText = await response.text();
|
||||||
console.error(`📋 오류 내용: ${errorText}`);
|
console.error(`📋 오류 내용: ${errorText}`);
|
||||||
|
|
||||||
|
// 오류 시 빈 배열로 설정하여 "등록된 문서가 없습니다" 메시지 표시
|
||||||
|
setFiles([]);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ 파일 목록 조회 네트워크 오류');
|
console.error('❌ 파일 목록 조회 네트워크 오류');
|
||||||
console.error('📋 오류 타입:', error instanceof Error ? error.name : typeof error);
|
console.error('📋 오류 타입:', error instanceof Error ? error.name : typeof error);
|
||||||
console.error('📋 오류 메시지:', error instanceof Error ? error.message : String(error));
|
console.error('📋 오류 메시지:', error instanceof Error ? error.message : String(error));
|
||||||
console.error('📋 전체 오류:', error);
|
console.error('📋 전체 오류:', error);
|
||||||
|
|
||||||
|
// 네트워크 오류 시에도 빈 배열로 설정
|
||||||
|
setFiles([]);
|
||||||
|
} finally {
|
||||||
|
setIsFileLoading(false); // 파일 관련 작업만을 위한 독립적인 로딩 상태 사용
|
||||||
}
|
}
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const searchFiles = async (searchTerm: string) => {
|
const searchFiles = useCallback(async (searchTerm: string) => {
|
||||||
console.log('🔍 서버 검색 실행:', searchTerm);
|
console.log('🔍 서버 검색 실행:', searchTerm);
|
||||||
await fetchFiles(searchTerm);
|
await fetchFiles(searchTerm);
|
||||||
};
|
}, [fetchFiles]);
|
||||||
|
|
||||||
const uploadFile = async (file: File): Promise<boolean> => {
|
const uploadFile = async (file: File, signal?: AbortSignal): Promise<string | null> => {
|
||||||
try {
|
try {
|
||||||
console.log('📤 파일 업로드 시작');
|
console.log('📤 파일 업로드 시작');
|
||||||
console.log('📋 파일명:', file.name);
|
console.log('📋 파일명:', file.name);
|
||||||
@ -97,7 +125,7 @@ export const FileProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
|||||||
if (!token) {
|
if (!token) {
|
||||||
console.log('🔒 파일 업로드를 위해서는 로그인이 필요합니다.');
|
console.log('🔒 파일 업로드를 위해서는 로그인이 필요합니다.');
|
||||||
alert('파일 업로드를 위해서는 로그인이 필요합니다.');
|
alert('파일 업로드를 위해서는 로그인이 필요합니다.');
|
||||||
return false;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
@ -114,6 +142,7 @@ export const FileProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
|||||||
'Authorization': `Bearer ${token}`,
|
'Authorization': `Bearer ${token}`,
|
||||||
},
|
},
|
||||||
body: formData,
|
body: formData,
|
||||||
|
signal: signal, // AbortSignal 추가
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('📥 업로드 응답 받음');
|
console.log('📥 업로드 응답 받음');
|
||||||
@ -121,26 +150,43 @@ export const FileProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
|||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
console.log('✅ 파일 업로드 성공');
|
console.log('✅ 파일 업로드 성공');
|
||||||
|
const responseData = await response.json();
|
||||||
|
const fileId = responseData.file_id;
|
||||||
|
console.log('📋 업로드된 파일 ID:', fileId);
|
||||||
|
|
||||||
// 업로드 성공 후 파일 목록 새로고침
|
// 업로드 성공 후 파일 목록 새로고침
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000)); // 1초 대기
|
await new Promise(resolve => setTimeout(resolve, 1000)); // 1초 대기
|
||||||
await fetchFiles();
|
await fetchFiles();
|
||||||
console.log('📁 파일 목록 새로고침 완료');
|
console.log('📁 파일 목록 새로고침 완료');
|
||||||
return true;
|
return fileId;
|
||||||
} else {
|
} else {
|
||||||
console.log('❌ 파일 업로드 실패');
|
console.log('❌ 파일 업로드 실패');
|
||||||
|
|
||||||
|
// 499 상태 코드 처리 (클라이언트 연결 끊어짐)
|
||||||
|
if (response.status === 499) {
|
||||||
|
console.log('🛑 클라이언트 연결 끊어짐 - 업로드 중단됨');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const errorData = await response.json();
|
const errorData = await response.json();
|
||||||
console.error('📋 오류 데이터:', errorData);
|
console.error('📋 오류 데이터:', errorData);
|
||||||
alert(`파일 업로드 실패: ${errorData.detail || '알 수 없는 오류가 발생했습니다.'}`);
|
alert(`파일 업로드 실패: ${errorData.detail || '알 수 없는 오류가 발생했습니다.'}`);
|
||||||
return false;
|
return null;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
// AbortError 처리 (업로드 중단)
|
||||||
|
if (error instanceof Error && error.name === 'AbortError') {
|
||||||
|
console.log('🛑 파일 업로드 중단됨 (AbortError)');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
console.error('❌ 파일 업로드 네트워크 오류');
|
console.error('❌ 파일 업로드 네트워크 오류');
|
||||||
console.error('📋 오류 타입:', error instanceof Error ? error.name : typeof error);
|
console.error('📋 오류 타입:', error instanceof Error ? error.name : typeof error);
|
||||||
console.error('📋 오류 메시지:', error instanceof Error ? error.message : String(error));
|
console.error('📋 오류 메시지:', error instanceof Error ? error.message : String(error));
|
||||||
console.error('📋 전체 오류:', error);
|
console.error('📋 전체 오류:', error);
|
||||||
const errorMessage = error instanceof Error ? error.message : '네트워크 오류가 발생했습니다.';
|
const errorMessage = error instanceof Error ? error.message : '네트워크 오류가 발생했습니다.';
|
||||||
alert(`파일 업로드 실패: ${errorMessage}`);
|
alert(`파일 업로드 실패: ${errorMessage}`);
|
||||||
return false;
|
return null;
|
||||||
} finally {
|
} finally {
|
||||||
console.log('🏁 파일 업로드 완료');
|
console.log('🏁 파일 업로드 완료');
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
@ -247,19 +293,19 @@ export const FileProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const refreshFiles = async () => {
|
const refreshFiles = useCallback(async () => {
|
||||||
console.log('🔄 파일 목록 새로고침 시작');
|
console.log('🔄 파일 목록 새로고침 시작');
|
||||||
await fetchFiles();
|
await fetchFiles();
|
||||||
console.log('✅ 파일 목록 새로고침 완료');
|
console.log('✅ 파일 목록 새로고침 완료');
|
||||||
};
|
}, [fetchFiles]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.log('🚀 FileContext 초기화 - 파일 목록 조회 시작');
|
console.log('🚀 FileContext 초기화 - 파일 목록 조회 시작');
|
||||||
fetchFiles();
|
fetchFiles();
|
||||||
}, []);
|
}, [fetchFiles]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FileContext.Provider value={{ files, uploadFile, uploadMultipleFiles, deleteFile, refreshFiles, searchFiles, isLoading }}>
|
<FileContext.Provider value={{ files, uploadFile, uploadMultipleFiles, deleteFile, refreshFiles, searchFiles, isLoading, isFileLoading }}>
|
||||||
{children}
|
{children}
|
||||||
</FileContext.Provider>
|
</FileContext.Provider>
|
||||||
);
|
);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user