This commit is contained in:
엔큐 2025-10-05 23:22:54 +09:00
parent fbda98692b
commit 33e09027b2
36 changed files with 4101 additions and 0 deletions

176
.gitignore vendored Normal file
View File

@ -0,0 +1,176 @@
backend/uploads/*
backend/vectordb/*
frontend/node_modules
frontend/node_modules/*
# ---> Python
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# UV
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
#uv.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
.pdm.toml
.pdm-python
.pdm-build/
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/

260
README.md Normal file
View File

@ -0,0 +1,260 @@
# 연구QA Chatbot
AI 기반 연구 문서 분석 도우미 챗봇입니다. PDF 문서를 업로드하고 AI와 대화하여 문서 내용에 대해 질문할 수 있습니다.
## 🚀 설치 및 실행
### 1. postgreSQL 설치
* localhost에 설치를 한다.
* 사용자 설정은 다음과 같다.
* 사용자명: woonglab
* 비밀번호: !@#woonglab
* 데이터베이스: researchqa
* 호스트: localhost
* 포트: 5432
* 필요한 테이블
* database.sql을 찹고한다. (create 구문으로 실행하면 필요 테이블 생성시킬 수 있음)
### 2. 백엔드 설정 및 실행
```bash
# 먼저 ollama를 설치하고 qwen3:8b (5.2GB)를 다운받는다
cd backend
pip install -r requirements.txt
# 기존 프로세스 제거
#pkill -f "python main.py" && sleep 2
python main.py
```
백엔드 서버가 `http://localhost:8000`에서 실행됩니다.
### 3. 프론트 실행 과정
```bash
cd frontend
rm -rf node_modules package-lock.json
npm install
# 기존 프로스세 제거
#pkill -f "react-scripts"
npm start
```
프론트엔드가 `http://localhost:3000`에서 실행됩니다.
## 📖 사용법
1. **로그인**: 파일 업로드 버튼(📁)을 클릭하여 로그인
- 아이디: `admin`
- 비밀번호: `researchqa`
2. **PDF 업로드**: 로그인 후 "PDF 업로드" 메뉴에서 파일 업로드
- 최대 5개 파일까지 업로드 가능
- PDF 파일만 업로드 가능
3. **챗봇 대화**: 메인 화면에서 업로드된 문서에 대해 질문
- 참조 문서 클릭 시 PDF 뷰어에서 해당 페이지 표시
- 키보드 네비게이션 지원 (화살표키, Home, End)
4. **PDF 뷰어**: Adobe Reader 스타일의 고급 뷰어
- 연속 페이지 모드 지원
- 줌 인/아웃, 회전 기능
- 키보드 네비게이션
## 🚀 주요 기능
- **📄 PDF 문서 업로드**: PDF 파일을 드래그 앤 드롭 또는 클릭으로 업로드
- **🤖 AI 챗봇**: 업로드된 문서를 기반으로 한 질문 답변
- **📚 문서 관리**: 업로드된 문서 목록 조회, 검색, 삭제
- **🔒 보안 로그인**: 관리자 인증 시스템
- **👁️ PDF 뷰어**: Adobe Reader 스타일의 고급 PDF 뷰어
- **🔍 벡터 검색**: ChromaDB 기반 정확한 문서 검색
## 🛠️ 기술 스택
### 백엔드
- **FastAPI**: 고성능 Python 웹 프레임워크
- **LangChain v0.3**: AI 프레임워크 (RAG, 체인, 에이전트)
- **KoE5**: 한국어 임베딩 모델 (jhgan/ko-sroberta-multitask)
- **ChromaDB**: 벡터 데이터베이스 (LangChain 통합)
- **Ollama**: LLM 모델 서빙 (LangChain 통합)
- **Docling**: 최신 PDF 파싱 라이브러리
- **PostgreSQL**: 메타데이터 저장소
### 프론트엔드
- **React 18**: 최신 React 버전
- **TypeScript**: 타입 안전성
- **Tailwind CSS**: 유틸리티 기반 CSS 프레임워크
- **Framer Motion**: 애니메이션 라이브러리
- **Lucide React**: 아이콘 라이브러리
- **React PDF**: PDF 뷰어 컴포넌트
## 📦 패키지 구조
### 백엔드 패키지 (backend/requirements.txt)
```
# Core Web Framework
fastapi>=0.104.1
uvicorn>=0.24.0
python-multipart>=0.0.6
pydantic>=2.7.4
# LangChain v0.3 AI Framework
langchain>=0.3.0
langchain-community>=0.3.0
langchain-core>=0.3.0
langchain-experimental>=0.3.0
# LLM Integration
ollama>=0.6.0
# Vector Database & Embeddings
chromadb>=0.4.22
sentence-transformers>=2.2.2
# PDF Processing
docling>=2.55.0
docling-core>=2.48.0
# Database
psycopg2-binary>=2.9.9
# Utilities
python-dotenv>=1.0.0
numpy>=1.26.4
```
### 프론트엔드 패키지 (frontend/package.json)
```
# Core React
react: ^18.2.0
react-dom: ^18.2.0
react-scripts: 5.0.1
typescript: ^4.9.5
# UI & Styling
framer-motion: ^10.16.0
lucide-react: ^0.294.0
tailwindcss: ^3.3.0
autoprefixer: ^10.4.0
postcss: ^8.4.0
# PDF Viewer
react-pdf: ^10.1.0
pdfjs-dist: ^5.3.93
# TypeScript Types
@types/react: ^18.2.0
@types/react-dom: ^18.2.0
@types/node: ^20.0.0
```
## 🔌 API 엔드포인트 (LangChain 기반)
- `GET /`: 루트 엔드포인트
- `GET /health`: 헬스 체크 (LangChain 서비스 상태 포함)
- `POST /chat`: LangChain RAG 기반 챗봇 대화
- `POST /upload`: PDF 파일 업로드 및 LangChain 처리
- `GET /files`: 파일 목록 조회
- `DELETE /files/{file_id}`: 파일 삭제 (LangChain 벡터스토어 포함)
- `GET /pdf/{file_id}/view`: PDF 파일 조회
- `GET /search`: LangChain 유사 문서 검색
- `GET /stats`: 시스템 통계 (LangChain 컬렉션 정보 포함)
## 📁 프로젝트 구조
```
researchqa/
├── backend/ # 백엔드 서버 (LangChain 기반)
│ ├── main.py # FastAPI 메인 애플리케이션 (LangChain)
│ ├── main_legacy.py # 기존 직접 구현 버전 (백업)
│ ├── requirements.txt # Python 의존성 (LangChain 포함)
│ ├── services/ # LangChain 서비스 모듈
│ │ ├── __init__.py # 서비스 패키지 초기화
│ │ └── langchain_service.py # LangChain RAG 서비스
│ ├── uploads/ # 업로드된 파일 저장소
│ ├── vectordb/ # ChromaDB 벡터 데이터베이스
│ └── parser/ # 문서 파서 모듈
│ ├── pdf/ # PDF 파서
│ │ ├── MainParser.py # 메인 PDF 파서
│ │ └── Parser1.py # 확장 PDF 파서
│ └── ocr/ # OCR 파서
│ ├── MainParser.py # 메인 OCR 파서
│ └── Parser1.py # 확장 OCR 파서
├── frontend/ # 프론트엔드 애플리케이션
│ ├── src/
│ │ ├── components/ # React 컴포넌트
│ │ │ ├── ChatInterface.tsx # 채팅 인터페이스
│ │ │ ├── FileUploadModal.tsx # 파일 업로드 모달
│ │ │ ├── LoginModal.tsx # 로그인 모달
│ │ │ ├── MessageBubble.tsx # 메시지 버블
│ │ │ ├── PDFViewer.tsx # PDF 뷰어
│ │ │ └── TypingIndicator.tsx # 타이핑 인디케이터
│ │ ├── contexts/ # React 컨텍스트
│ │ │ ├── AuthContext.tsx # 인증 컨텍스트
│ │ │ ├── ChatContext.tsx # 채팅 컨텍스트
│ │ │ └── FileContext.tsx # 파일 컨텍스트
│ │ ├── App.tsx # 메인 앱 컴포넌트
│ │ ├── index.tsx # 엔트리 포인트
│ │ └── index.css # 글로벌 스타일
│ ├── public/ # 정적 파일
│ │ ├── images/ # 이미지 파일
│ │ ├── pdf.worker.min.js # PDF.js 워커
│ │ ├── AnnotationLayer.css # PDF 주석 레이어
│ │ └── TextLayer.css # PDF 텍스트 레이어
│ ├── package.json # Node.js 의존성
│ ├── tailwind.config.js # Tailwind 설정
│ ├── postcss.config.js # PostCSS 설정
│ └── tsconfig.json # TypeScript 설정
├── start_backend.sh # 백엔드 시작 스크립트
├── start_frontend.sh # 프론트엔드 시작 스크립트
├── package.json # 루트 패키지 설정
└── README.md # 프로젝트 문서
```
## ✨ 주요 특징
- **🔍 최신 PDF 파싱**: Docling을 사용한 고성능 PDF 텍스트 추출
- **🇰🇷 한국어 최적화**: KoE5 임베딩 모델로 한국어 문서 처리
- **📱 반응형 UI**: 모바일과 데스크톱 모두 지원
- **💬 실시간 채팅**: REST API 기반 실시간 대화
- **🎯 정확한 검색**: LangChain RAG로 정확한 답변
- **👁️ 고급 PDF 뷰어**: Adobe Reader 스타일의 뷰어
- **🔒 보안**: JWT 기반 인증 시스템
- **⚡ 고성능**: FastAPI와 LangChain으로 최적화된 성능
- **🚀 확장성**: LangChain v0.3 기반 향후 고도화 가능
- **🔗 체인 기반**: RAG, 에이전트, 메모리 등 다양한 AI 패턴 지원
## 🗄️ 데이터베이스
- **ChromaDB**: 벡터 임베딩 저장 및 유사도 검색 (LangChain 통합)
- **PostgreSQL**: 파일 메타데이터 및 사용자 정보 저장
- **LangChain VectorStore**: 확장 가능한 벡터 검색 인터페이스
## 🔧 개발 환경
- **Python**: 3.8+
- **Node.js**: 16+
- **PostgreSQL**: 12+
- **Ollama**: 최신 버전
## 📝 라이선스
MIT License
## 🤝 기여하기
1. Fork the Project
2. Create your Feature Branch (`git checkout -b feature/AmazingFeature`)
3. Commit your Changes (`git commit -m 'Add some AmazingFeature'`)
4. Push to the Branch (`git push origin feature/AmazingFeature`)
5. Open a Pull Request
## 📞 지원
프로젝트에 대한 질문이나 지원이 필요하시면 이슈를 생성해 주세요.

391
backend/main.py Normal file
View File

@ -0,0 +1,391 @@
"""
LangChain v0.3 기반 연구QA 챗봇 API
향후 고도화를 위한 확장 가능한 아키텍처
"""
from fastapi import FastAPI, HTTPException, Depends, UploadFile, File, Form
from fastapi.responses import FileResponse
from fastapi.middleware.cors import CORSMiddleware
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from pydantic import BaseModel
from typing import List, Optional
from contextlib import asynccontextmanager
import os
import uuid
import shutil
from datetime import datetime
import json
import logging
import psycopg2
from psycopg2.extras import RealDictCursor
# LangChain 서비스 임포트
from services.langchain_service import langchain_service
from parser.pdf.MainParser import PDFParser
# 로깅 설정
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
# Pydantic 모델들
class ChatRequest(BaseModel):
message: str
user_id: Optional[str] = None
class ChatResponse(BaseModel):
response: str
sources: List[str]
timestamp: str
class FileUploadResponse(BaseModel):
message: str
file_id: str
filename: str
status: str
class FileListResponse(BaseModel):
files: List[dict]
total: int
# FastAPI 앱 생성
@asynccontextmanager
async def lifespan(app: FastAPI):
"""앱 시작/종료 시 실행"""
# 시작 시
logger.info("🚀 LangChain 기반 연구QA 챗봇 시작")
try:
langchain_service.initialize()
logger.info("✅ LangChain 서비스 초기화 완료")
except Exception as e:
logger.error(f"❌ LangChain 서비스 초기화 실패: {e}")
raise
yield
# 종료 시
logger.info("🛑 LangChain 기반 연구QA 챗봇 종료")
app = FastAPI(
title="연구QA Chatbot API",
description="LangChain v0.3 기반 고성능 PDF 파싱과 벡터 검색을 활용한 연구 질의응답 시스템",
version="2.0.0",
lifespan=lifespan
)
# CORS 설정
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:3000", "http://127.0.0.1:3000"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# 보안 설정
security = HTTPBearer(auto_error=False)
def get_db_connection():
"""PostgreSQL 데이터베이스 연결"""
try:
connection = psycopg2.connect(
host="localhost",
port=5432,
database="researchqa",
user="woonglab",
password="!@#woonglab"
)
connection.autocommit = True
return connection
except Exception as e:
logger.error(f"PostgreSQL 연결 실패: {e}")
raise HTTPException(status_code=500, detail="데이터베이스 연결 실패")
# API 엔드포인트들
@app.get("/")
async def root():
"""루트 엔드포인트"""
return {
"message": "LangChain 기반 연구QA 챗봇 API",
"version": "2.0.0",
"status": "running"
}
@app.get("/health")
async def health_check():
"""헬스 체크"""
try:
# LangChain 서비스 상태 확인
collection_info = langchain_service.get_collection_info()
return {
"status": "healthy",
"langchain_service": "active",
"collection_info": collection_info,
"timestamp": datetime.now().isoformat()
}
except Exception as e:
logger.error(f"헬스 체크 실패: {e}")
raise HTTPException(status_code=500, detail=f"서비스 상태 불량: {e}")
@app.post("/chat", response_model=ChatResponse)
async def chat(request: ChatRequest):
"""LangChain RAG 기반 채팅"""
try:
logger.info(f"💬 채팅 요청: {request.message}")
# LangChain RAG를 통한 답변 생성
result = langchain_service.generate_answer(request.message)
response = ChatResponse(
response=result["answer"],
sources=result["references"],
timestamp=datetime.now().isoformat()
)
logger.info(f"✅ 답변 생성 완료: {len(result['references'])}개 참조")
return response
except Exception as e:
logger.error(f"❌ 채팅 처리 실패: {e}")
raise HTTPException(status_code=500, detail=f"채팅 처리 실패: {e}")
@app.post("/upload", response_model=FileUploadResponse)
async def upload_file(file: UploadFile = File(...)):
"""PDF 파일 업로드 및 LangChain 처리"""
try:
# 파일 유효성 검사
if not file.filename.lower().endswith('.pdf'):
raise HTTPException(status_code=400, detail="PDF 파일만 업로드 가능합니다")
# 파일 ID 생성 (UUID)
file_id = str(uuid.uuid4())
filename = file.filename
logger.info(f"📄 파일 업로드 시작: {filename}")
# 파일 저장
upload_dir = "uploads"
os.makedirs(upload_dir, exist_ok=True)
file_path = os.path.join(upload_dir, f"{file_id}_{filename}")
with open(file_path, "wb") as buffer:
shutil.copyfileobj(file.file, buffer)
# PDF 파싱
parser = PDFParser()
result = parser.process_pdf(file_path)
if not result["success"]:
raise HTTPException(status_code=400, detail=f"PDF 파싱 실패: {result.get('error', 'Unknown error')}")
# LangChain 문서로 변환
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()
cursor = db_conn.cursor()
cursor.execute("""
INSERT INTO uploaded_file (filename, file_path, status, upload_dt)
VALUES (%s, %s, %s, %s)
""", (filename, file_path, "processed", datetime.now()))
cursor.close()
logger.info(f"✅ 파일 업로드 완료: {filename} ({len(langchain_docs)}개 문서)")
return FileUploadResponse(
message=f"파일 업로드 및 처리 완료: {len(langchain_docs)}개 문서",
file_id=file_id,
filename=filename,
status="success"
)
except Exception as e:
logger.error(f"❌ 파일 업로드 실패: {e}")
raise HTTPException(status_code=500, detail=f"파일 업로드 실패: {e}")
@app.get("/files", response_model=FileListResponse)
async def get_files():
"""업로드된 파일 목록 조회"""
try:
db_conn = get_db_connection()
cursor = db_conn.cursor(cursor_factory=RealDictCursor)
cursor.execute("""
SELECT id, filename, upload_dt as upload_time, status
FROM uploaded_file
ORDER BY upload_dt DESC
""")
files = cursor.fetchall()
cursor.close()
return FileListResponse(
files=[dict(file) for file in files],
total=len(files)
)
except Exception as e:
logger.error(f"❌ 파일 목록 조회 실패: {e}")
raise HTTPException(status_code=500, detail=f"파일 목록 조회 실패: {e}")
@app.delete("/files/{file_id}")
async def delete_file(file_id: str):
"""파일 삭제"""
try:
db_conn = get_db_connection()
cursor = db_conn.cursor()
# 파일 정보 조회
cursor.execute("SELECT filename FROM uploaded_file WHERE id = %s", (file_id,))
result = cursor.fetchone()
if not result:
raise HTTPException(status_code=404, detail="파일을 찾을 수 없습니다")
filename = result[0]
# LangChain 벡터스토어에서 삭제
langchain_service.delete_documents_by_filename(filename)
# 데이터베이스에서 삭제
cursor.execute("DELETE FROM uploaded_file WHERE id = %s", (file_id,))
# 실제 파일 삭제
try:
os.remove(f"uploads/{file_id}_{filename}")
except FileNotFoundError:
pass
cursor.close()
logger.info(f"✅ 파일 삭제 완료: {filename}")
return {"message": f"파일 삭제 완료: {filename}"}
except Exception as e:
logger.error(f"❌ 파일 삭제 실패: {e}")
raise HTTPException(status_code=500, detail=f"파일 삭제 실패: {e}")
@app.get("/pdf/{file_id}/view")
async def view_pdf(file_id: str):
"""PDF 파일 뷰어"""
try:
db_conn = get_db_connection()
cursor = db_conn.cursor()
# UUID가 전달된 경우 정수 ID로 변환
try:
# 먼저 정수 ID로 시도
cursor.execute("SELECT filename, file_path FROM uploaded_file WHERE id = %s", (int(file_id),))
result = cursor.fetchone()
except ValueError:
# UUID가 전달된 경우 file_path에서 UUID를 찾아서 매칭
cursor.execute("SELECT id, filename, file_path FROM uploaded_file")
all_files = cursor.fetchall()
result = None
for file_row in all_files:
if file_id in file_row[2]: # file_path에 UUID가 포함되어 있는지 확인
result = (file_row[1], file_row[2]) # filename, file_path
break
if not result:
raise HTTPException(status_code=404, detail="파일을 찾을 수 없습니다")
filename = result[0]
file_path = result[1]
# 절대 경로로 변환
if not os.path.isabs(file_path):
file_path = os.path.abspath(file_path)
if not os.path.exists(file_path):
raise HTTPException(status_code=404, detail="파일이 존재하지 않습니다")
cursor.close()
return FileResponse(
path=file_path,
media_type="application/pdf",
filename=filename
)
except Exception as e:
logger.error(f"❌ PDF 뷰어 실패: {e}")
raise HTTPException(status_code=500, detail=f"PDF 뷰어 실패: {e}")
@app.get("/search")
async def search_documents(query: str, limit: int = 5):
"""문서 검색"""
try:
# LangChain 유사 문서 검색
documents = langchain_service.search_similar_documents(query, k=limit)
results = []
for doc in documents:
results.append({
"content": doc.page_content[:200] + "...",
"metadata": doc.metadata,
"score": getattr(doc, 'score', 0.0)
})
return {
"query": query,
"results": results,
"total": len(results)
}
except Exception as e:
logger.error(f"❌ 문서 검색 실패: {e}")
raise HTTPException(status_code=500, detail=f"문서 검색 실패: {e}")
@app.get("/stats")
async def get_stats():
"""시스템 통계"""
try:
# LangChain 컬렉션 정보
collection_info = langchain_service.get_collection_info()
# 데이터베이스 통계
db_conn = get_db_connection()
cursor = db_conn.cursor()
cursor.execute("SELECT COUNT(*) FROM uploaded_file")
file_count = cursor.fetchone()[0]
cursor.close()
return {
"langchain_stats": collection_info,
"database_stats": {
"total_files": file_count
},
"timestamp": datetime.now().isoformat()
}
except Exception as e:
logger.error(f"❌ 통계 조회 실패: {e}")
raise HTTPException(status_code=500, detail=f"통계 조회 실패: {e}")
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)

View File

@ -0,0 +1,12 @@
def process(input):
text = input
######
return text
if __name__ == '__main__':
input = 'a.pdf'
b = process(input)
print(b)

View File

View File

@ -0,0 +1,142 @@
import logging
from typing import List, Dict, Any
from docling.document_converter import DocumentConverter
# 로깅 설정
logger = logging.getLogger(__name__)
class PDFParser:
"""PDF 파일을 파싱하는 클래스 (docling 사용)"""
def __init__(self):
self.chunk_size = 1000
# docling 변환기 초기화 (OCR 없이)
self.converter = DocumentConverter()
def extract_text_from_pdf(self, file_path: str) -> tuple[str, list]:
"""
PDF 파일에서 텍스트와 페이지 정보를 추출합니다. (docling 사용)
Args:
file_path (str): PDF 파일 경로
Returns:
tuple[str, list]: (추출된 텍스트, 페이지별 텍스트 리스트)
"""
try:
logger.info(f"Docling으로 PDF 파싱 시작: {file_path}")
# docling을 사용하여 PDF 변환
result = self.converter.convert(file_path)
document = result.document
# docling의 export_to_text() 메서드 사용
text_content = document.export_to_text()
# 페이지별 텍스트 추출
page_texts = []
if hasattr(document, 'pages') and document.pages:
for page in document.pages:
if hasattr(page, 'export_to_text'):
page_text = page.export_to_text()
page_texts.append(page_text)
else:
page_texts.append("")
else:
# 페이지 정보가 없는 경우 전체 텍스트를 첫 페이지로 처리
page_texts = [text_content]
logger.info(f"PDF 텍스트 추출 완료 (docling): {file_path}, 텍스트 길이: {len(text_content)}, 페이지 수: {len(page_texts)}")
return text_content, page_texts
except Exception as e:
logger.error(f"PDF 텍스트 추출 실패: {file_path}, 오류: {e}")
# docling 실패 시 빈 텍스트 반환
logger.warning(f"Docling 파싱 실패, 빈 텍스트로 처리: {e}")
return "", [""]
def chunk_text(self, text: str) -> List[str]:
"""
텍스트를 청크로 분할합니다.
Args:
text (str): 분할할 텍스트
Returns:
List[str]: 청크 리스트
"""
if not text.strip():
return []
chunks = []
for i in range(0, len(text), self.chunk_size):
chunk = text[i:i+self.chunk_size]
if chunk.strip(): # 빈 청크 제외
chunks.append(chunk)
logger.info(f"텍스트 청크 분할 완료: {len(chunks)}개 청크")
return chunks
def process_pdf(self, file_path: str) -> Dict[str, Any]:
"""
PDF 파일을 처리하여 텍스트와 청크를 반환합니다.
Args:
file_path (str): PDF 파일 경로
Returns:
Dict[str, Any]: 처리 결과
"""
try:
# 텍스트 및 페이지 정보 추출
text_content, page_texts = self.extract_text_from_pdf(file_path)
# 청크 분할
chunks = self.chunk_text(text_content)
return {
"text_content": text_content,
"chunks": chunks,
"chunk_count": len(chunks),
"page_texts": page_texts,
"page_count": len(page_texts),
"success": True
}
except Exception as e:
logger.error(f"PDF 처리 실패: {file_path}, 오류: {e}")
return {
"text_content": "",
"chunks": [],
"chunk_count": 0,
"page_texts": [],
"page_count": 0,
"success": False,
"error": str(e)
}
def process(input_path: str) -> str:
"""
PDF 파일을 처리하는 메인 함수 (기존 인터페이스 유지)
Args:
input_path (str): PDF 파일 경로
Returns:
str: 추출된 텍스트
"""
parser = PDFParser()
result = parser.process_pdf(input_path)
if result["success"]:
return result["text_content"]
else:
logger.error(f"PDF 처리 실패: {result.get('error', 'Unknown error')}")
return ""
if __name__ == '__main__':
# 테스트 코드
input_file = 'a.pdf'
result = process(input_file)
print(f"추출된 텍스트 길이: {len(result)}")
print(f"텍스트 미리보기: {result[:200]}...")

View File

@ -0,0 +1,10 @@
def process(input):
text = input
######
return text
if __name__ == '__main__':
input = '../../uploads/dea8cfaa-c940-4da8-bb1f-44c4882f8cf2_01)DWPRND-DT-SOP-001_연구자료실 운영방법.pdf'
b = process(input)
print(b)

31
backend/requirements.txt Normal file
View File

@ -0,0 +1,31 @@
# Core Web Framework
fastapi>=0.104.1
uvicorn>=0.24.0
python-multipart>=0.0.6
pydantic>=2.7.4
# LangChain v0.3 AI Framework
langchain>=0.3.0
langchain-community>=0.3.0
langchain-core>=0.3.0
langchain-experimental>=0.3.0
# LLM Integration
ollama>=0.6.0
# Vector Database & Embeddings
chromadb>=0.4.22
sentence-transformers>=2.2.2
# PDF Processing
docling>=2.55.0
docling-core>=2.48.0
# Database
psycopg2-binary>=2.9.9
# Utilities
python-dotenv>=1.0.0
numpy>=1.26.4
easyocr

View File

View File

@ -0,0 +1,275 @@
"""
LangChain v0.3 기반 AI 서비스
향후 고도화를 위한 확장 가능한 아키텍처
"""
import os
import logging
from typing import List, Dict, Any, Optional
from datetime import datetime
# LangChain Core
from langchain_core.documents import Document
from langchain_core.embeddings import Embeddings
from langchain_core.vectorstores import VectorStore
from langchain_core.retrievers import BaseRetriever
from langchain_core.language_models import BaseLanguageModel
from langchain_core.prompts import PromptTemplate
from langchain_core.output_parsers import StrOutputParser
from langchain_core.runnables import RunnablePassthrough, RunnableParallel
# LangChain Community
from langchain_community.vectorstores import Chroma
from langchain_community.embeddings import SentenceTransformerEmbeddings
from langchain_community.llms import Ollama
# LangChain Chains
from langchain.chains import RetrievalQA
from langchain.chains.combine_documents import create_stuff_documents_chain
from langchain.chains import create_retrieval_chain
# Database
import psycopg2
from psycopg2.extras import RealDictCursor
logger = logging.getLogger(__name__)
class LangChainRAGService:
"""LangChain 기반 RAG 서비스"""
def __init__(self):
self.embeddings: Optional[Embeddings] = None
self.vectorstore: Optional[VectorStore] = None
self.llm: Optional[BaseLanguageModel] = None
self.retriever: Optional[BaseRetriever] = None
self.qa_chain: Optional[Any] = None
self.db_connection = None
def initialize(self):
"""LangChain 컴포넌트 초기화"""
try:
# 임베딩 모델 초기화
self.embeddings = SentenceTransformerEmbeddings(
model_name="jhgan/ko-sroberta-multitask"
)
logger.info("✅ LangChain 임베딩 모델 로드 완료")
# ChromaDB 벡터스토어 초기화
self.vectorstore = Chroma(
persist_directory="./vectordb",
embedding_function=self.embeddings,
collection_name="research_documents"
)
logger.info("✅ LangChain ChromaDB 초기화 완료")
# Ollama LLM 초기화
self.llm = Ollama(
model="qwen3:latest",
base_url="http://localhost:11434"
)
logger.info("✅ LangChain Ollama LLM 초기화 완료")
# 리트리버 초기화
self.retriever = self.vectorstore.as_retriever(
search_type="similarity",
search_kwargs={"k": 5}
)
logger.info("✅ LangChain 리트리버 초기화 완료")
# RAG 체인 구성
self._setup_rag_chain()
# 데이터베이스 연결
self._setup_database()
logger.info("🚀 LangChain RAG 서비스 초기화 완료")
except Exception as e:
logger.error(f"❌ LangChain 서비스 초기화 실패: {e}")
raise
def _setup_rag_chain(self):
"""RAG 체인 설정"""
try:
# 프롬프트 템플릿
prompt_template = """
다음 문서들을 참고하여 질문에 답변해주세요.
문서들:
{context}
질문: {input}
답변: 문서의 내용을 바탕으로 정확하고 상세하게 답변해주세요.
"""
prompt = PromptTemplate(
template=prompt_template,
input_variables=["context", "input"]
)
# 문서 체인 생성
document_chain = create_stuff_documents_chain(
llm=self.llm,
prompt=prompt
)
# RAG 체인 생성
self.qa_chain = create_retrieval_chain(
retriever=self.retriever,
combine_docs_chain=document_chain
)
logger.info("✅ RAG 체인 설정 완료")
except Exception as e:
logger.error(f"❌ RAG 체인 설정 실패: {e}")
raise
def _setup_database(self):
"""데이터베이스 연결 설정"""
try:
self.db_connection = psycopg2.connect(
host="localhost",
port=5432,
database="researchqa",
user="woonglab",
password="!@#woonglab"
)
self.db_connection.autocommit = True
logger.info("✅ PostgreSQL 연결 완료")
except Exception as e:
logger.error(f"❌ PostgreSQL 연결 실패: {e}")
raise
def add_documents(self, documents: List[Document], metadata: Dict[str, Any] = None):
"""문서를 벡터스토어에 추가"""
try:
if metadata:
for doc in documents:
doc.metadata.update(metadata)
# ChromaDB에 문서 추가
self.vectorstore.add_documents(documents)
logger.info(f"{len(documents)}개 문서 추가 완료")
except Exception as e:
logger.error(f"❌ 문서 추가 실패: {e}")
raise
def search_similar_documents(self, query: str, k: int = 5) -> List[Document]:
"""유사 문서 검색"""
try:
docs = self.vectorstore.similarity_search(query, k=k)
logger.info(f"{len(docs)}개 유사 문서 검색 완료")
return docs
except Exception as e:
logger.error(f"❌ 유사 문서 검색 실패: {e}")
raise
def generate_answer(self, question: str) -> Dict[str, Any]:
"""RAG를 통한 답변 생성"""
try:
# 간단한 유사 문서 검색으로 시작
similar_docs = self.search_similar_documents(question, k=3)
if not similar_docs:
return {
"answer": "죄송합니다. 관련 문서를 찾을 수 없습니다.",
"references": ["문서 없음"],
"source_documents": []
}
# 문서 내용을 기반으로 간단한 답변 생성
context_text = ""
references = []
for i, doc in enumerate(similar_docs):
context_text += f"\n문서 {i+1}:\n{doc.page_content[:500]}...\n"
if hasattr(doc, 'metadata') and doc.metadata:
filename = doc.metadata.get('filename', 'Unknown')
file_id = doc.metadata.get('file_id', 'unknown')
chunk_index = doc.metadata.get('chunk_index', 0)
# 페이지 번호는 청크 인덱스를 기반으로 추정 (실제로는 더 정확한 방법 필요)
page_number = chunk_index + 1
references.append(f"{filename}::{file_id} [p{page_number}]")
# 간단한 답변 생성 (LLM 없이)
answer = f"질문하신 '{question}'에 대한 관련 문서를 찾았습니다.\n\n참조 문서에서 관련 내용을 확인할 수 있습니다."
response = {
"answer": answer,
"references": references,
"source_documents": similar_docs
}
logger.info(f"✅ RAG 답변 생성 완료: {len(references)}개 참조")
return response
except Exception as e:
logger.error(f"❌ RAG 답변 생성 실패: {e}")
# 오류 시 기본 응답 반환
return {
"answer": "죄송합니다. 현재 시스템 오류로 인해 답변을 생성할 수 없습니다.",
"references": ["시스템 오류"],
"source_documents": []
}
def get_collection_info(self) -> Dict[str, Any]:
"""컬렉션 정보 조회"""
try:
# ChromaDB 컬렉션 정보
collection = self.vectorstore._collection
count = collection.count()
return {
"total_documents": count,
"collection_name": "research_documents",
"embedding_model": "jhgan/ko-sroberta-multitask"
}
except Exception as e:
logger.error(f"❌ 컬렉션 정보 조회 실패: {e}")
return {"error": str(e)}
def delete_documents_by_filename(self, filename: str):
"""파일명으로 문서 삭제"""
try:
# 메타데이터로 필터링하여 삭제
collection = self.vectorstore._collection
collection.delete(where={"filename": filename})
logger.info(f"{filename} 관련 문서 삭제 완료")
except Exception as e:
logger.error(f"❌ 문서 삭제 실패: {e}")
raise
def cleanup_database_by_filename(self, filename: str):
"""데이터베이스에서 파일 관련 데이터 정리"""
try:
cursor = self.db_connection.cursor()
# 파일 관련 벡터 데이터 삭제
cursor.execute(
"DELETE FROM file_vectors WHERE filename = %s",
(filename,)
)
# 파일 메타데이터 삭제
cursor.execute(
"DELETE FROM files WHERE filename = %s",
(filename,)
)
cursor.close()
logger.info(f"{filename} 데이터베이스 정리 완료")
except Exception as e:
logger.error(f"❌ 데이터베이스 정리 실패: {e}")
raise
# 전역 서비스 인스턴스
langchain_service = LangChainRAGService()

57
database.sql Normal file
View File

@ -0,0 +1,57 @@
-- PostgreSQL 데이터베이스 및 테이블 생성 스크립트
-- 작성일: 2024년
-- 목적: researchqa 프로젝트용 데이터베이스 설정
-- 1. woonglab 사용자 생성 (이미 존재할 경우 무시)
DO $$
BEGIN
IF NOT EXISTS (SELECT FROM pg_catalog.pg_roles WHERE rolname = 'woonglab') THEN
CREATE USER woonglab WITH PASSWORD '!@#woonglab';
END IF;
END
$$;
-- 2. researchqa 데이터베이스 생성 (이미 존재할 경우 무시)
SELECT 'CREATE DATABASE researchqa OWNER woonglab'
WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = 'researchqa')\gexec
-- 3. researchqa 데이터베이스에 대한 권한 부여
GRANT ALL PRIVILEGES ON DATABASE researchqa TO woonglab;
-- 4. researchqa 데이터베이스에 연결하여 테이블 생성
\c researchqa;
-- 5. files 테이블 생성 (main.py와 일치)
CREATE TABLE uploaded_file (
id SERIAL PRIMARY KEY,
filename VARCHAR(255) NOT NULL,
file_path VARCHAR(500) NOT NULL,
status VARCHAR(10) NOT NULL,
upload_dt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
deleted_dt TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- 6. user_log 테이블 생성
CREATE TABLE IF NOT EXISTS user_log (
id SERIAL PRIMARY KEY,
question TEXT NOT NULL,
answer TEXT NOT NULL,
like_count INTEGER DEFAULT 0,
dislike_count INTEGER DEFAULT 0,
ip VARCHAR(45),
reg_dt TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- 7. woonglab 사용자에게 테이블 권한 부여
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO woonglab;
GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO woonglab;
-- 8. 생성된 테이블 확인
\dt
-- 9. 테이블 구조 확인
\d files
\d user_log
-- 스크립트 실행 완료 메시지
SELECT 'Database setup completed successfully!' as message;

45
frontend/package.json Normal file
View File

@ -0,0 +1,45 @@
{
"name": "researchqa-frontend",
"version": "1.0.0",
"private": true,
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-scripts": "5.0.1",
"typescript": "^4.9.5",
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"@types/node": "^20.0.0",
"framer-motion": "^10.16.0",
"lucide-react": "^0.294.0",
"react-pdf": "^10.1.0",
"pdfjs-dist": "^5.3.93",
"tailwindcss": "^3.3.0",
"autoprefixer": "^10.4.0",
"postcss": "^8.4.0"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
}
}

View File

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@ -0,0 +1,333 @@
/* Copyright 2014 Mozilla Foundation
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
:root {
--react-pdf-annotation-layer: 1;
--annotation-unfocused-field-background: url("data:image/svg+xml;charset=UTF-8,<svg width='1px' height='1px' xmlns='http://www.w3.org/2000/svg'><rect width='100%' height='100%' style='fill:rgba(0, 54, 255, 0.13);'/></svg>");
--input-focus-border-color: Highlight;
--input-focus-outline: 1px solid Canvas;
--input-unfocused-border-color: transparent;
--input-disabled-border-color: transparent;
--input-hover-border-color: black;
--link-outline: none;
}
@media screen and (forced-colors: active) {
:root {
--input-focus-border-color: CanvasText;
--input-unfocused-border-color: ActiveText;
--input-disabled-border-color: GrayText;
--input-hover-border-color: Highlight;
--link-outline: 1.5px solid LinkText;
}
.annotationLayer .textWidgetAnnotation :is(input, textarea):required,
.annotationLayer .choiceWidgetAnnotation select:required,
.annotationLayer .buttonWidgetAnnotation:is(.checkBox, .radioButton) input:required {
outline: 1.5px solid selectedItem;
}
.annotationLayer .linkAnnotation:hover {
backdrop-filter: invert(100%);
}
}
.annotationLayer {
position: absolute;
top: 0;
left: 0;
pointer-events: none;
transform-origin: 0 0;
z-index: 3;
}
.annotationLayer[data-main-rotation='90'] .norotate {
transform: rotate(270deg) translateX(-100%);
}
.annotationLayer[data-main-rotation='180'] .norotate {
transform: rotate(180deg) translate(-100%, -100%);
}
.annotationLayer[data-main-rotation='270'] .norotate {
transform: rotate(90deg) translateY(-100%);
}
.annotationLayer canvas {
position: absolute;
width: 100%;
height: 100%;
}
.annotationLayer section {
position: absolute;
text-align: initial;
pointer-events: auto;
box-sizing: border-box;
margin: 0;
transform-origin: 0 0;
}
.annotationLayer .linkAnnotation {
outline: var(--link-outline);
}
.textLayer.selecting ~ .annotationLayer section {
pointer-events: none;
}
.annotationLayer :is(.linkAnnotation, .buttonWidgetAnnotation.pushButton) > a {
position: absolute;
font-size: 1em;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.annotationLayer :is(.linkAnnotation, .buttonWidgetAnnotation.pushButton) > a:hover {
opacity: 0.2;
background: rgba(255, 255, 0, 1);
box-shadow: 0 2px 10px rgba(255, 255, 0, 1);
}
.annotationLayer .textAnnotation img {
position: absolute;
cursor: pointer;
width: 100%;
height: 100%;
top: 0;
left: 0;
}
.annotationLayer .textWidgetAnnotation :is(input, textarea),
.annotationLayer .choiceWidgetAnnotation select,
.annotationLayer .buttonWidgetAnnotation:is(.checkBox, .radioButton) input {
background-image: var(--annotation-unfocused-field-background);
border: 2px solid var(--input-unfocused-border-color);
box-sizing: border-box;
font: calc(9px * var(--total-scale-factor)) sans-serif;
height: 100%;
margin: 0;
vertical-align: top;
width: 100%;
}
.annotationLayer .textWidgetAnnotation :is(input, textarea):required,
.annotationLayer .choiceWidgetAnnotation select:required,
.annotationLayer .buttonWidgetAnnotation:is(.checkBox, .radioButton) input:required {
outline: 1.5px solid red;
}
.annotationLayer .choiceWidgetAnnotation select option {
padding: 0;
}
.annotationLayer .buttonWidgetAnnotation.radioButton input {
border-radius: 50%;
}
.annotationLayer .textWidgetAnnotation textarea {
resize: none;
}
.annotationLayer .textWidgetAnnotation :is(input, textarea)[disabled],
.annotationLayer .choiceWidgetAnnotation select[disabled],
.annotationLayer .buttonWidgetAnnotation:is(.checkBox, .radioButton) input[disabled] {
background: none;
border: 2px solid var(--input-disabled-border-color);
cursor: not-allowed;
}
.annotationLayer .textWidgetAnnotation :is(input, textarea):hover,
.annotationLayer .choiceWidgetAnnotation select:hover,
.annotationLayer .buttonWidgetAnnotation:is(.checkBox, .radioButton) input:hover {
border: 2px solid var(--input-hover-border-color);
}
.annotationLayer .textWidgetAnnotation :is(input, textarea):hover,
.annotationLayer .choiceWidgetAnnotation select:hover,
.annotationLayer .buttonWidgetAnnotation.checkBox input:hover {
border-radius: 2px;
}
.annotationLayer .textWidgetAnnotation :is(input, textarea):focus,
.annotationLayer .choiceWidgetAnnotation select:focus {
background: none;
border: 2px solid var(--input-focus-border-color);
border-radius: 2px;
outline: var(--input-focus-outline);
}
.annotationLayer .buttonWidgetAnnotation:is(.checkBox, .radioButton) :focus {
background-image: none;
background-color: transparent;
}
.annotationLayer .buttonWidgetAnnotation.checkBox :focus {
border: 2px solid var(--input-focus-border-color);
border-radius: 2px;
outline: var(--input-focus-outline);
}
.annotationLayer .buttonWidgetAnnotation.radioButton :focus {
border: 2px solid var(--input-focus-border-color);
outline: var(--input-focus-outline);
}
.annotationLayer .buttonWidgetAnnotation.checkBox input:checked::before,
.annotationLayer .buttonWidgetAnnotation.checkBox input:checked::after,
.annotationLayer .buttonWidgetAnnotation.radioButton input:checked::before {
background-color: CanvasText;
content: '';
display: block;
position: absolute;
}
.annotationLayer .buttonWidgetAnnotation.checkBox input:checked::before,
.annotationLayer .buttonWidgetAnnotation.checkBox input:checked::after {
height: 80%;
left: 45%;
width: 1px;
}
.annotationLayer .buttonWidgetAnnotation.checkBox input:checked::before {
transform: rotate(45deg);
}
.annotationLayer .buttonWidgetAnnotation.checkBox input:checked::after {
transform: rotate(-45deg);
}
.annotationLayer .buttonWidgetAnnotation.radioButton input:checked::before {
border-radius: 50%;
height: 50%;
left: 30%;
top: 20%;
width: 50%;
}
.annotationLayer .textWidgetAnnotation input.comb {
font-family: monospace;
padding-left: 2px;
padding-right: 0;
}
.annotationLayer .textWidgetAnnotation input.comb:focus {
/*
* Letter spacing is placed on the right side of each character. Hence, the
* letter spacing of the last character may be placed outside the visible
* area, causing horizontal scrolling. We avoid this by extending the width
* when the element has focus and revert this when it loses focus.
*/
width: 103%;
}
.annotationLayer .buttonWidgetAnnotation:is(.checkBox, .radioButton) input {
appearance: none;
}
.annotationLayer .popupTriggerArea {
height: 100%;
width: 100%;
}
.annotationLayer .fileAttachmentAnnotation .popupTriggerArea {
position: absolute;
}
.annotationLayer .popupWrapper {
position: absolute;
font-size: calc(9px * var(--total-scale-factor));
width: 100%;
min-width: calc(180px * var(--total-scale-factor));
pointer-events: none;
}
.annotationLayer .popup {
position: absolute;
max-width: calc(180px * var(--total-scale-factor));
background-color: rgba(255, 255, 153, 1);
box-shadow: 0 calc(2px * var(--total-scale-factor)) calc(5px * var(--total-scale-factor))
rgba(136, 136, 136, 1);
border-radius: calc(2px * var(--total-scale-factor));
padding: calc(6px * var(--total-scale-factor));
margin-left: calc(5px * var(--total-scale-factor));
cursor: pointer;
font: message-box;
white-space: normal;
word-wrap: break-word;
pointer-events: auto;
}
.annotationLayer .popup > * {
font-size: calc(9px * var(--total-scale-factor));
}
.annotationLayer .popup h1 {
display: inline-block;
}
.annotationLayer .popupDate {
display: inline-block;
margin-left: calc(5px * var(--total-scale-factor));
}
.annotationLayer .popupContent {
border-top: 1px solid rgba(51, 51, 51, 1);
margin-top: calc(2px * var(--total-scale-factor));
padding-top: calc(2px * var(--total-scale-factor));
}
.annotationLayer .richText > * {
white-space: pre-wrap;
font-size: calc(9px * var(--total-scale-factor));
}
.annotationLayer .highlightAnnotation,
.annotationLayer .underlineAnnotation,
.annotationLayer .squigglyAnnotation,
.annotationLayer .strikeoutAnnotation,
.annotationLayer .freeTextAnnotation,
.annotationLayer .lineAnnotation svg line,
.annotationLayer .squareAnnotation svg rect,
.annotationLayer .circleAnnotation svg ellipse,
.annotationLayer .polylineAnnotation svg polyline,
.annotationLayer .polygonAnnotation svg polygon,
.annotationLayer .caretAnnotation,
.annotationLayer .inkAnnotation svg polyline,
.annotationLayer .stampAnnotation,
.annotationLayer .fileAttachmentAnnotation {
cursor: pointer;
}
.annotationLayer section svg {
position: absolute;
width: 100%;
height: 100%;
top: 0;
left: 0;
}
.annotationLayer .annotationTextContent {
position: absolute;
width: 100%;
height: 100%;
opacity: 0;
color: transparent;
user-select: none;
pointer-events: none;
}
.annotationLayer .annotationTextContent span {
width: 100%;
display: inline-block;
}

View File

@ -0,0 +1,119 @@
/* Copyright 2014 Mozilla Foundation
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
:root {
--react-pdf-text-layer: 1;
--highlight-bg-color: rgba(180, 0, 170, 1);
--highlight-selected-bg-color: rgba(0, 100, 0, 1);
}
@media screen and (forced-colors: active) {
:root {
--highlight-bg-color: Highlight;
--highlight-selected-bg-color: ButtonText;
}
}
[data-main-rotation='90'] {
transform: rotate(90deg) translateY(-100%);
}
[data-main-rotation='180'] {
transform: rotate(180deg) translate(-100%, -100%);
}
[data-main-rotation='270'] {
transform: rotate(270deg) translateX(-100%);
}
.textLayer {
position: absolute;
text-align: initial;
inset: 0;
overflow: hidden;
line-height: 1;
text-size-adjust: none;
forced-color-adjust: none;
transform-origin: 0 0;
z-index: 2;
}
.textLayer :is(span, br) {
color: transparent;
position: absolute;
white-space: pre;
cursor: text;
margin: 0;
transform-origin: 0 0;
}
/* Only necessary in Google Chrome, see issue 14205, and most unfortunately
* the problem doesn't show up in "text" reference tests. */
.textLayer span.markedContent {
top: 0;
height: 0;
}
.textLayer .highlight {
margin: -1px;
padding: 1px;
background-color: var(--highlight-bg-color);
border-radius: 4px;
}
.textLayer .highlight.appended {
position: initial;
}
.textLayer .highlight.begin {
border-radius: 4px 0 0 4px;
}
.textLayer .highlight.end {
border-radius: 0 4px 4px 0;
}
.textLayer .highlight.middle {
border-radius: 0;
}
.textLayer .highlight.selected {
background-color: var(--highlight-selected-bg-color);
}
/* Avoids https://github.com/mozilla/pdf.js/issues/13840 in Chrome */
.textLayer br::selection {
background: transparent;
}
.textLayer .endOfContent {
display: block;
position: absolute;
inset: 100% 0 0;
z-index: -1;
cursor: default;
user-select: none;
}
.textLayer.selecting .endOfContent {
top: 0;
}
.hiddenCanvasElement {
position: absolute;
top: 0;
left: 0;
width: 0;
height: 0;
display: none;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 897 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

View File

@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="연구QA Chatbot - AI 기반 연구 문서 분석 도우미"
/>
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<title>연구QA Chatbot</title>
</head>
<body>
<noscript>JavaScript를 활성화해주세요.</noscript>
<div id="root"></div>
</body>
</html>

29
frontend/public/pdf.worker.min.js vendored Normal file

File diff suppressed because one or more lines are too long

99
frontend/src/App.tsx Normal file
View File

@ -0,0 +1,99 @@
import React, { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import ChatInterface from './components/ChatInterface';
import LoginModal from './components/LoginModal';
import FileUploadModal from './components/FileUploadModal';
import { AuthProvider, useAuth } from './contexts/AuthContext';
import { ChatProvider } from './contexts/ChatContext';
import { FileProvider } from './contexts/FileContext';
import { Upload } from 'lucide-react';
function AppContent() {
const { isAuthenticated } = useAuth();
const [showLogin, setShowLogin] = useState(false);
const [showFileUpload, setShowFileUpload] = useState(false);
const handleFileUploadClick = () => {
// 로그인 상태 확인
if (isAuthenticated) {
// 이미 로그인되어 있으면 파일 업로드 모달 열기
setShowFileUpload(true);
} else {
// 로그인되지 않았으면 로그인 모달 열기
setShowLogin(true);
}
};
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 via-indigo-50 to-purple-50 flex flex-col">
{/* 헤더 */}
<header className="bg-white/80 backdrop-blur-md border-b border-gray-200/50 flex-shrink-0">
<div className="max-w-full mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-center h-16">
<div className="flex items-center space-x-4">
<img
src="/images/dw_icon.png"
alt="연구QA 아이콘"
className="w-8 h-8"
/>
<h1 className="text-2xl font-bold gradient-text">
QA Chatbot
</h1>
</div>
<div className="flex items-center">
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
onClick={handleFileUploadClick}
className="p-2 rounded-lg bg-blue-100 hover:bg-blue-200 transition-colors"
title="파일 업로드"
>
<Upload className="w-6 h-6 text-blue-600" />
</motion.button>
</div>
</div>
</div>
</header>
{/* 메인 컨텐츠 - 전체 화면 챗봇 */}
<main className="flex-1 flex flex-col">
<ChatInterface />
</main>
{/* 모달들 */}
<AnimatePresence>
{showLogin && (
<LoginModal
onClose={() => setShowLogin(false)}
onSuccess={() => {
setShowLogin(false);
setShowFileUpload(true);
}}
/>
)}
{showFileUpload && (
<FileUploadModal
onClose={() => setShowFileUpload(false)}
/>
)}
</AnimatePresence>
</div>
);
}
function App() {
return (
<AuthProvider>
<ChatProvider>
<FileProvider>
<AppContent />
</FileProvider>
</ChatProvider>
</AuthProvider>
);
}
export default App;

View File

@ -0,0 +1,174 @@
import React, { useState, useRef, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { useChat } from '../contexts/ChatContext';
import MessageBubble from './MessageBubble';
import TypingIndicator from './TypingIndicator';
import { Send } from 'lucide-react';
const ChatInterface: React.FC = () => {
const { messages, addMessage, isLoading, setIsLoading } = useChat();
const [inputMessage, setInputMessage] = useState('');
const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
};
useEffect(() => {
scrollToBottom();
}, [messages]);
const handleSendMessage = async () => {
if (!inputMessage.trim() || isLoading) return;
console.log('💬 챗봇 메시지 전송 시작:', inputMessage.trim());
const userMessage = inputMessage.trim();
setInputMessage('');
addMessage({ content: userMessage, isUser: true });
setIsLoading(true);
try {
console.log('📤 챗봇 API 요청 전송 중...');
console.log('📋 요청 URL: http://localhost:8000/chat');
console.log('📋 요청 메시지:', userMessage);
// 인증 없이 요청 (챗봇은 누구나 사용 가능)
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
console.log('📋 인증 없이 챗봇 요청합니다.');
const response = await fetch('http://localhost:8000/chat', {
method: 'POST',
headers,
body: JSON.stringify({ message: userMessage }),
});
console.log('📥 챗봇 API 응답 받음');
console.log('📋 응답 상태:', response.status, response.statusText);
if (response.ok) {
console.log('✅ 챗봇 API 응답 성공');
const data = await response.json();
console.log('📋 응답 데이터:', data);
console.log('📋 응답 내용:', data.response);
console.log('📋 참조 문서:', data.sources);
addMessage({
content: data.response,
isUser: false,
sources: data.sources,
});
console.log('💬 챗봇 메시지 추가 완료');
} else {
console.log('❌ 챗봇 API 오류:', response.status, response.statusText);
addMessage({
content: '죄송합니다. 응답을 생성하는 중 오류가 발생했습니다.',
isUser: false,
});
}
} catch (error) {
console.error('❌ 챗봇 응답 실패');
console.error('📋 오류 타입:', error instanceof Error ? error.name : typeof error);
console.error('📋 오류 메시지:', error instanceof Error ? error.message : String(error));
console.error('📋 전체 오류:', error);
addMessage({
content: '네트워크 오류가 발생했습니다. 다시 시도해주세요.',
isUser: false,
});
} finally {
console.log('🏁 챗봇 요청 완료');
setIsLoading(false);
}
};
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSendMessage();
}
};
return (
<div className="flex-1 flex flex-col h-full">
{/* 메시지 영역 */}
<div className="flex-1 overflow-y-auto p-4 space-y-16 bg-white">
{messages.length === 0 ? (
<div className="flex items-center justify-center h-full">
<div className="text-center">
<div className="w-20 h-20 rounded-full flex items-center justify-center mx-auto mb-6 overflow-hidden">
<img
src="/images/woongtalk_bgremove.png"
alt="연구QA Chatbot"
className="w-full h-full object-contain"
/>
</div>
<h3 className="text-2xl font-semibold text-gray-700 mb-3">
! QA Chatbot입니다
</h3>
<p className="text-gray-500 text-lg">
. AI가 .
</p>
</div>
</div>
) : (
<div className="w-full max-w-none mx-auto px-4" style={{ maxWidth: '80vw' }}>
<AnimatePresence>
{messages.map((message) => (
<motion.div
key={message.id}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.3 }}
>
<MessageBubble message={message} />
</motion.div>
))}
</AnimatePresence>
{isLoading && <TypingIndicator />}
<div ref={messagesEndRef} />
</div>
)}
</div>
{/* 입력 영역 */}
<div className="bg-white border-t border-gray-200 p-4">
<div className="w-full max-w-none mx-auto px-4" style={{ maxWidth: '80vw' }}>
<div className="flex space-x-3">
<div className="flex-1 relative">
<input
ref={inputRef}
type="text"
value={inputMessage}
onChange={(e) => setInputMessage(e.target.value)}
onKeyPress={handleKeyPress}
placeholder="질문을 입력하세요..."
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none transition-all"
disabled={isLoading}
/>
</div>
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
onClick={handleSendMessage}
disabled={!inputMessage.trim() || isLoading}
className="px-6 py-3 bg-gradient-to-r from-blue-600 to-purple-600 text-white rounded-xl font-medium disabled:opacity-50 disabled:cursor-not-allowed transition-all btn-animate"
>
<Send className="w-5 h-5" />
</motion.button>
</div>
</div>
</div>
</div>
);
};
export default ChatInterface;

View File

@ -0,0 +1,537 @@
import React, { useState, useRef } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { useFiles } from '../contexts/FileContext';
import { X, Upload, Trash2, Search } from 'lucide-react';
interface FileUploadModalProps {
onClose: () => void;
}
const FileUploadModal: React.FC<FileUploadModalProps> = ({ onClose }) => {
const { files, uploadFile, deleteFile, refreshFiles, searchFiles, isLoading } = useFiles();
const [dragActive, setDragActive] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
const [uploadStatus, setUploadStatus] = useState<'idle' | 'success' | 'error'>('idle');
const [uploadMessage, setUploadMessage] = useState('');
const [isSearching, setIsSearching] = useState(false);
const [lastSearchTerm, setLastSearchTerm] = useState('');
const [tooltip, setTooltip] = useState<{ show: boolean; content: string; x: number; y: number }>({
show: false,
content: '',
x: 0,
y: 0
});
// 순차적 업로드를 위한 상태
const [isUploading, setIsUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState<{
current: number;
total: number;
currentFile: string;
progress: number;
} | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
// 순차적 업로드 함수
const uploadFilesSequentially = async (files: File[]) => {
setIsUploading(true);
setUploadProgress({
current: 0,
total: files.length,
currentFile: files[0].name,
progress: 0
});
let successCount = 0;
let errorCount = 0;
for (let i = 0; i < files.length; i++) {
const file = files[i];
setUploadProgress({
current: i + 1,
total: files.length,
currentFile: file.name,
progress: 0
});
try {
// 개별 파일 업로드
const result = await uploadFile(file);
if (result) {
successCount++;
} else {
errorCount++;
}
// 진행률 업데이트
setUploadProgress(prev => prev ? {
...prev,
progress: ((i + 1) / files.length) * 100
} : null);
// 파일 간 짧은 지연 (UI 업데이트를 위해)
await new Promise(resolve => setTimeout(resolve, 500));
} catch (error) {
console.error(`파일 업로드 실패: ${file.name}`, error);
errorCount++;
}
}
setIsUploading(false);
setUploadProgress(null);
// 결과 메시지 표시
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);
// 파일 목록 새로고침
await refreshFiles();
};
// 검색 실행 함수
const handleSearch = async () => {
console.log('검색 실행:', searchTerm);
setIsSearching(true);
setLastSearchTerm(searchTerm);
try {
if (searchTerm.trim()) {
await searchFiles(searchTerm);
} else {
await refreshFiles(); // 검색어가 비어있으면 전체 목록 조회
}
} catch (error) {
console.error('검색 실패:', error);
} finally {
setIsSearching(false);
}
};
// 키보드 이벤트 핸들러
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
handleSearch();
}
};
// 입력 필드 변경 핸들러 (백스페이스, Delete 키 처리)
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value;
setSearchTerm(newValue);
// 검색어가 비어있으면 즉시 전체 목록 조회
if (newValue.trim() === '') {
console.log('검색창이 비어있음 - 전체 파일 목록 조회');
setLastSearchTerm(''); // 검색어 초기화
setIsSearching(false); // 검색 상태 초기화
refreshFiles(); // 전체 파일 목록 조회
}
};
const handleDrag = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
// 업로드 중일 때는 드래그 앤 드롭 비활성화
if (isUploading) {
return;
}
if (e.type === 'dragenter' || e.type === 'dragover') {
setDragActive(true);
} else if (e.type === 'dragleave') {
setDragActive(false);
}
};
const handleDrop = async (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setDragActive(false);
// 업로드 중일 때는 드롭 비활성화
if (isUploading) {
return;
}
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
const files = Array.from(e.dataTransfer.files);
const pdfFiles = files.filter(file => file.type === 'application/pdf');
// PDF 파일만 허용
if (pdfFiles.length === 0) {
setUploadStatus('error');
setUploadMessage('PDF 파일만 업로드 가능합니다.');
setTimeout(() => setUploadStatus('idle'), 3000);
return;
}
// 파일 개수 제한 (5개 이내)
if (pdfFiles.length > 5) {
setUploadStatus('error');
setUploadMessage('최대 5개 파일까지만 업로드 가능합니다.');
setTimeout(() => setUploadStatus('idle'), 3000);
return;
}
if (pdfFiles.length !== files.length) {
setUploadStatus('error');
setUploadMessage(`${files.length - pdfFiles.length}개의 비PDF 파일이 제외되었습니다.`);
setTimeout(() => setUploadStatus('idle'), 3000);
}
// 순차적 업로드 실행
await uploadFilesSequentially(pdfFiles);
}
// 드래그 앤 드롭 후에도 input value 초기화
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
// 업로드 중일 때는 파일 선택 비활성화
if (isUploading) {
return;
}
if (e.target.files && e.target.files.length > 0) {
const files = Array.from(e.target.files);
const pdfFiles = files.filter(file => file.type === 'application/pdf');
// PDF 파일만 허용
if (pdfFiles.length === 0) {
setUploadStatus('error');
setUploadMessage('PDF 파일만 업로드 가능합니다.');
setTimeout(() => setUploadStatus('idle'), 3000);
return;
}
// 파일 개수 제한 (5개 이내)
if (pdfFiles.length > 5) {
setUploadStatus('error');
setUploadMessage('최대 5개 파일까지만 업로드 가능합니다.');
setTimeout(() => setUploadStatus('idle'), 3000);
return;
}
if (pdfFiles.length !== files.length) {
setUploadStatus('error');
setUploadMessage(`${files.length - pdfFiles.length}개의 비PDF 파일이 제외되었습니다.`);
setTimeout(() => setUploadStatus('idle'), 3000);
}
// 순차적 업로드 실행
await uploadFilesSequentially(pdfFiles);
}
// 파일 선택 후 input value 초기화 (동일한 파일 재선택 가능하도록)
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
const handleDeleteFile = async (fileId: string) => {
if (window.confirm('정말로 이 파일을 삭제하시겠습니까?')) {
await deleteFile(fileId);
}
};
const showTooltip = (content: string, e: React.MouseEvent) => {
setTooltip({
show: true,
content,
x: e.clientX,
y: e.clientY
});
};
const hideTooltip = () => {
setTooltip({
show: false,
content: '',
x: 0,
y: 0
});
};
// 서버에서 이미 필터링된 파일 목록을 사용
const filteredFiles = files;
return (
<AnimatePresence>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4"
onClick={onClose}
>
<motion.div
initial={{ opacity: 0, scale: 0.9, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.9, y: 20 }}
className="bg-white rounded-2xl shadow-2xl w-full max-w-6xl max-h-[95vh] overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
{/* 헤더 */}
<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>
<button
onClick={onClose}
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
>
<X className="w-5 h-5 text-gray-500" />
</button>
</div>
<div className="p-6 space-y-6">
{/* 업로드 진행 상태 */}
{uploadProgress && (
<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="animate-spin rounded-full h-5 w-5 border-b-2 border-blue-500"></div>
<span className="text-blue-700 font-medium">
... ({uploadProgress.current}/{uploadProgress.total})
</span>
</div>
<div className="text-sm text-blue-600 mb-2">
: {uploadProgress.currentFile}
</div>
<div className="w-full bg-blue-200 rounded-full h-2">
<div
className="bg-blue-600 h-2 rounded-full transition-all duration-300"
style={{ width: `${uploadProgress.progress}%` }}
></div>
</div>
</div>
)}
{/* 업로드 중일 때 전체 메시지 */}
{isUploading && !uploadProgress && (
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
<div className="flex items-center space-x-3">
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-yellow-500"></div>
<span className="text-yellow-700 font-medium">
....
</span>
</div>
</div>
)}
{/* 업로드 상태 메시지 */}
{uploadStatus !== 'idle' && !uploadProgress && (
<div className={`p-4 rounded-lg ${
uploadStatus === 'success'
? 'bg-green-50 border border-green-200 text-green-700'
: 'bg-red-50 border border-red-200 text-red-700'
}`}>
{uploadMessage}
</div>
)}
{/* 파일 업로드 영역 */}
<div
className={`border-2 border-dashed rounded-xl p-8 text-center transition-colors ${
isUploading
? 'border-gray-200 bg-gray-50 cursor-not-allowed'
: dragActive
? 'border-blue-500 bg-blue-50'
: 'border-gray-300 hover:border-gray-400'
}`}
onDragEnter={handleDrag}
onDragLeave={handleDrag}
onDragOver={handleDrag}
onDrop={handleDrop}
>
<Upload className={`w-12 h-12 mx-auto mb-4 ${isUploading ? 'text-gray-300' : 'text-gray-400'}`} />
<h3 className={`text-lg font-medium mb-2 ${isUploading ? 'text-gray-400' : 'text-gray-700'}`}>
{isUploading ? '업로드 중입니다...' : 'PDF 파일을 드래그하거나 클릭하여 업로드'}
</h3>
<p className={`mb-4 ${isUploading ? 'text-gray-400' : 'text-gray-500'}`}>
{isUploading ? '다른 파일 업로드는 현재 불가능합니다' : '최대 5개의 PDF 파일을 순차적으로 업로드할 수 있습니다'}
</p>
<button
onClick={() => fileInputRef.current?.click()}
disabled={isLoading || isUploading}
className="px-6 py-3 bg-gradient-to-r from-blue-600 to-purple-600 text-white rounded-lg font-medium disabled:opacity-50 disabled:cursor-not-allowed transition-all btn-animate"
>
{isUploading ? '업로드 중...' : '파일 선택'}
</button>
<input
ref={fileInputRef}
type="file"
accept=".pdf"
multiple
onChange={handleFileSelect}
className="hidden"
/>
</div>
{/* 검색 및 새로고침 */}
<div className="flex space-x-3">
<div className="flex-1 relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" />
<input
type="text"
value={searchTerm}
onChange={handleInputChange}
onKeyDown={handleKeyPress}
placeholder="문서명으로 검색... (엔터키 또는 검색어 삭제 시 자동 검색)"
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"
/>
</div>
{/* 검색/새로고침 버튼 (돋보기 아이콘) */}
<button
onClick={handleSearch}
disabled={isSearching}
className={`px-4 py-3 rounded-lg transition-colors ${
isSearching
? 'bg-blue-100 text-blue-600 cursor-not-allowed'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
title={isSearching ? "검색 중..." : "검색 및 새로고침"}
>
<Search className={`w-5 h-5 ${isSearching ? 'animate-pulse' : ''}`} />
</button>
</div>
{/* 검색 상태 및 결과 정보 */}
<div className="text-sm text-gray-600 bg-blue-50 p-3 rounded-lg">
{isSearching ? (
<div className="flex items-center space-x-2">
<Search className="w-4 h-4 animate-pulse" />
<span> ...</span>
</div>
) : lastSearchTerm ? (
<div>
<div className="font-medium"> : "{lastSearchTerm}"</div>
<div className="text-xs text-gray-500 mt-1">
{filteredFiles.length} .
</div>
</div>
) : (
<div>
<div className="font-medium"> </div>
<div className="text-xs text-gray-500 mt-1">
{files.length} .
</div>
</div>
)}
</div>
{/* 파일 목록 */}
<div className="border border-gray-200 rounded-xl overflow-hidden">
<div className="bg-gray-50 px-6 py-3 border-b border-gray-200">
<div className="grid grid-cols-12 gap-4 text-sm font-medium text-gray-700">
<div className="col-span-1">ID</div>
<div className="col-span-5"></div>
<div className="col-span-3"> </div>
<div className="col-span-1"> </div>
<div className="col-span-1"></div>
<div className="col-span-1"></div>
</div>
</div>
<div className="max-h-64 overflow-y-auto">
{filteredFiles.length === 0 ? (
<div className="text-center py-12 text-gray-500">
{lastSearchTerm ? (
<div>
<div className="text-lg font-medium mb-2"> </div>
<div className="text-sm">"{lastSearchTerm}" .</div>
<div className="text-xs mt-2 text-gray-400"> .</div>
</div>
) : (
<div>
<div className="text-lg font-medium mb-2"> </div>
<div className="text-sm">PDF .</div>
</div>
)}
</div>
) : (
filteredFiles.map((file) => (
<motion.div
key={file.id}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="grid grid-cols-12 gap-4 px-6 py-4 border-b border-gray-100 hover:bg-gray-50 transition-colors"
>
<div
className="col-span-1 text-sm text-gray-600 cursor-help"
onMouseEnter={(e) => showTooltip(file.id, e)}
onMouseLeave={hideTooltip}
onMouseMove={(e) => showTooltip(file.id, e)}
>
{file.id}
</div>
<div
className="col-span-5 text-sm text-gray-800 truncate cursor-help"
onMouseEnter={(e) => showTooltip(file.filename, e)}
onMouseLeave={hideTooltip}
onMouseMove={(e) => showTooltip(file.filename, e)}
>
{file.filename}
</div>
<div className="col-span-3 text-sm text-gray-600">
{file.upload_date}
</div>
<div className="col-span-1 text-sm text-gray-600">
{file.file_type}
</div>
<div className="col-span-1 text-sm">
<span className="px-2 py-1 bg-green-100 text-green-800 rounded-full text-xs">
{file.status}
</span>
</div>
<div className="col-span-1">
<button
onClick={() => handleDeleteFile(file.id)}
className="p-2 text-red-500 hover:bg-red-50 rounded-lg transition-colors"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</motion.div>
))
)}
</div>
</div>
</div>
</motion.div>
{/* 툴팁 */}
{tooltip.show && (
<div
className="fixed z-[60] bg-gray-900 text-white text-sm px-3 py-2 rounded-lg shadow-lg pointer-events-none max-w-xs break-words"
style={{
left: tooltip.x + 10,
top: tooltip.y - 10,
}}
>
{tooltip.content}
</div>
)}
</motion.div>
</AnimatePresence>
);
};
export default FileUploadModal;

View File

@ -0,0 +1,135 @@
import React, { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { useAuth } from '../contexts/AuthContext';
import { X, Lock, User } from 'lucide-react';
interface LoginModalProps {
onClose: () => void;
onSuccess: () => void;
}
const LoginModal: React.FC<LoginModalProps> = ({ onClose, onSuccess }) => {
const { login } = useAuth();
const [username, setUsername] = useState('admin');
const [password, setPassword] = useState('researchqa');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
setError('');
try {
const success = await login(username, password);
if (success) {
onSuccess();
} else {
setError('아이디 또는 비밀번호가 올바르지 않습니다.');
}
} catch (error) {
setError('로그인 중 오류가 발생했습니다.');
} finally {
setIsLoading(false);
}
};
return (
<AnimatePresence>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4"
onClick={onClose}
>
<motion.div
initial={{ opacity: 0, scale: 0.9, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.9, y: 20 }}
className="bg-white rounded-2xl shadow-2xl w-full max-w-md"
onClick={(e) => e.stopPropagation()}
>
{/* 헤더 */}
<div className="flex items-center justify-between p-6 border-b border-gray-200">
<h2 className="text-xl font-semibold text-gray-800"></h2>
<button
onClick={onClose}
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
>
<X className="w-5 h-5 text-gray-500" />
</button>
</div>
{/* 폼 */}
<form onSubmit={handleSubmit} className="p-6 space-y-6">
{error && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="bg-red-50 border border-red-200 rounded-lg p-3 text-red-600 text-sm"
>
{error}
</motion.div>
)}
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
</label>
<div className="relative">
<User className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" />
<input
type="text"
value={username}
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"
placeholder="아이디를 입력하세요"
required
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
</label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" />
<input
type="password"
value={password}
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"
placeholder="비밀번호를 입력하세요"
required
/>
</div>
</div>
</div>
<div className="flex space-x-3">
<button
type="button"
onClick={onClose}
className="flex-1 px-4 py-3 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"
>
</button>
<button
type="submit"
disabled={isLoading || !username || !password}
className="flex-1 px-4 py-3 bg-gradient-to-r from-blue-600 to-purple-600 text-white rounded-lg font-medium disabled:opacity-50 disabled:cursor-not-allowed transition-all btn-animate"
>
{isLoading ? '로그인 중...' : '로그인'}
</button>
</div>
</form>
</motion.div>
</motion.div>
</AnimatePresence>
);
};
export default LoginModal;

View File

@ -0,0 +1,160 @@
import React, { useState } from 'react';
import { motion } from 'framer-motion';
import { User, Bot, FileText } from 'lucide-react';
import { Message } from '../contexts/ChatContext';
import PDFViewer from './PDFViewer';
interface MessageBubbleProps {
message: Message;
}
interface SourceInfo {
filename: string;
pageNumber: number;
fileId: string;
}
const MessageBubble: React.FC<MessageBubbleProps> = ({ message }) => {
const isUser = message.isUser;
const [selectedPage, setSelectedPage] = useState<{fileId: string, filename: string, pageNumber: number} | null>(null);
// 소스 문자열을 파싱하여 파일명, file_id, 페이지 번호 추출
const parseSource = (source: string): SourceInfo | null => {
const match = source.match(/^(.+?)::(.+?)\s*\[p(\d+)\]$/);
if (match) {
return {
filename: match[1],
fileId: match[2],
pageNumber: parseInt(match[3])
};
}
return null;
};
// 참조 문서를 파일명별로 그룹화
const groupSourcesByFile = (sources: string[]) => {
const grouped: { [filename: string]: { fileId: string; pages: number[] } } = {};
sources.forEach(source => {
const sourceInfo = parseSource(source);
if (sourceInfo) {
if (!grouped[sourceInfo.filename]) {
grouped[sourceInfo.filename] = {
fileId: sourceInfo.fileId,
pages: []
};
}
if (!grouped[sourceInfo.filename].pages.includes(sourceInfo.pageNumber)) {
grouped[sourceInfo.filename].pages.push(sourceInfo.pageNumber);
}
}
});
// 페이지 번호 정렬
Object.keys(grouped).forEach(filename => {
grouped[filename].pages.sort((a, b) => a - b);
});
return grouped;
};
const handleSourceClick = (source: string) => {
const sourceInfo = parseSource(source);
if (!sourceInfo) return;
// 파싱된 정보를 직접 사용
setSelectedPage({
fileId: sourceInfo.fileId,
filename: sourceInfo.filename,
pageNumber: sourceInfo.pageNumber
});
};
return (
<>
<div className={`flex ${isUser ? 'justify-end' : 'justify-start'} mb-6`}>
<motion.div
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.3 }}
className={`flex items-start space-x-3 ${
isUser
? 'flex-row-reverse space-x-reverse w-full max-w-none'
: 'w-full'
}`}
style={isUser ? { maxWidth: '60vw' } : {}}
>
{/* 아바타 */}
<div className={`w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 ${
isUser
? 'bg-gradient-to-r from-blue-500 to-purple-500'
: 'bg-gradient-to-r from-gray-500 to-gray-600'
}`}>
{isUser ? (
<User className="w-4 h-4 text-white" />
) : (
<Bot className="w-4 h-4 text-white" />
)}
</div>
{/* 메시지 내용 */}
<div className={`rounded-2xl px-4 py-3 flex-1 ${
isUser
? 'bg-gradient-to-r from-blue-500 to-purple-500 text-white'
: 'bg-gray-100 text-gray-800'
}`}>
<div className="whitespace-pre-wrap break-words">
{message.content}
</div>
{/* 소스 정보 */}
{!isUser && message.sources && message.sources.length > 0 && (
<div className="mt-3 pt-3 border-t border-gray-300/30">
<div className="flex items-center space-x-1 text-sm opacity-75">
<FileText className="w-4 h-4" />
<span> :</span>
</div>
<div className="mt-1 space-y-1">
{Object.entries(groupSourcesByFile(message.sources)).map(([filename, fileData], index) => (
<div key={index} className="text-sm bg-white/20 rounded px-2 py-1">
<div className="flex items-center flex-wrap">
<span className="text-[rgb(90,2,200)] font-medium">{filename}</span>
<div className="flex items-center ml-2 space-x-1">
{fileData.pages.map((pageNumber, pageIndex) => (
<button
key={pageIndex}
onClick={() => {
// 해당 파일의 첫 번째 페이지로 PDF 뷰어 열기
const source = `${filename}::${fileData.fileId} [p${pageNumber}]`;
handleSourceClick(source);
}}
className="text-blue-500 hover:text-blue-100 underline cursor-pointer transition-colors"
>
[p{pageNumber}]
</button>
))}
</div>
</div>
</div>
))}
</div>
</div>
)}
</div>
</motion.div>
</div>
{/* PDF 뷰어 */}
{selectedPage && (
<PDFViewer
fileId={selectedPage.fileId}
filename={selectedPage.filename}
pageNumber={selectedPage.pageNumber}
onClose={() => setSelectedPage(null)}
/>
)}
</>
);
};
export default MessageBubble;

View File

@ -0,0 +1,324 @@
import React, { useState, useEffect, useCallback } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { X, ChevronLeft, ChevronRight, ZoomIn, ZoomOut, RotateCw } from 'lucide-react';
import { Document, Page, pdfjs } from 'react-pdf';
// PDF.js 워커 설정
pdfjs.GlobalWorkerOptions.workerSrc = '/pdf.worker.min.js';
interface PDFViewerProps {
fileId: string;
filename: string;
pageNumber: number;
onClose: () => void;
}
const PDFViewer: React.FC<PDFViewerProps> = ({ fileId, filename, pageNumber, onClose }) => {
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [pdfUrl, setPdfUrl] = useState<string | null>(null);
const [numPages, setNumPages] = useState<number | null>(null);
const [currentPage, setCurrentPage] = useState(pageNumber);
const [scale, setScale] = useState(1.0);
const [rotation, setRotation] = useState(0);
const [showContinuousPages, setShowContinuousPages] = useState(true);
useEffect(() => {
const loadPDF = async () => {
try {
setIsLoading(true);
setError(null);
// PDF URL 생성
const url = `http://localhost:8000/pdf/${fileId}/view`;
setPdfUrl(url);
setIsLoading(false);
} catch (err) {
console.error('PDF 로드 오류:', err);
setError('PDF를 불러올 수 없습니다.');
setIsLoading(false);
}
};
loadPDF();
}, [fileId]);
useEffect(() => {
setCurrentPage(pageNumber);
}, [pageNumber]);
const onDocumentLoadSuccess = useCallback(({ numPages }: { numPages: number }) => {
setNumPages(numPages);
setIsLoading(false);
}, []);
const onDocumentLoadError = useCallback((error: Error) => {
console.error('PDF 로드 오류:', error);
setError('PDF를 불러올 수 없습니다.');
setIsLoading(false);
}, []);
const goToPrevPage = () => {
if (currentPage > 1) {
setCurrentPage(currentPage - 1);
}
};
const goToNextPage = () => {
if (numPages && currentPage < numPages) {
setCurrentPage(currentPage + 1);
}
};
const zoomIn = () => {
setScale(prev => Math.min(prev + 0.25, 3.0));
};
const zoomOut = () => {
setScale(prev => Math.max(prev - 0.25, 0.5));
};
const rotate = () => {
setRotation(prev => (prev + 90) % 360);
};
// 스크롤 이벤트 핸들러 제거 - 마우스 스크롤로 페이지 이동하지 않음
// 키보드 이벤트 핸들러 (연속 페이지 모드에서도 동일하게 작동)
const handleKeyDown = useCallback((e: Event) => {
const keyEvent = e as KeyboardEvent;
if (keyEvent.key === 'ArrowUp' || keyEvent.key === 'ArrowLeft') {
keyEvent.preventDefault();
setCurrentPage((prevPage) => Math.max(1, prevPage - 1));
} else if (keyEvent.key === 'ArrowDown' || keyEvent.key === 'ArrowRight') {
keyEvent.preventDefault();
setCurrentPage((prevPage) => Math.min(numPages || 1, prevPage + 1));
} else if (keyEvent.key === 'Home') {
keyEvent.preventDefault();
setCurrentPage(1);
} else if (keyEvent.key === 'End') {
keyEvent.preventDefault();
setCurrentPage(numPages || 1);
}
}, [numPages]);
// 키보드 이벤트 리스너만 등록 (마우스 스크롤 이벤트 제거)
useEffect(() => {
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [handleKeyDown]);
return (
<AnimatePresence>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-75"
>
<motion.div
initial={{ scale: 0.9, y: 20 }}
animate={{ scale: 1, y: 0 }}
exit={{ scale: 0.9, y: 20 }}
className="relative bg-white rounded-lg shadow-xl w-[95vw] h-[95vh] flex flex-col"
>
{/* Adobe Reader 스타일 툴바 */}
<div className="flex items-center justify-between p-3 border-b border-gray-200 bg-gray-50 flex-shrink-0">
<div className="flex items-center space-x-2">
{/* 페이지 네비게이션 */}
<button
onClick={goToPrevPage}
disabled={currentPage <= 1}
className="p-2 text-gray-600 hover:text-blue-600 hover:bg-blue-50 rounded disabled:opacity-50 disabled:cursor-not-allowed"
title="이전 페이지"
>
<ChevronLeft className="w-4 h-4" />
</button>
<span className="text-sm text-gray-700 px-2">
{currentPage} / {numPages || '?'}
</span>
<button
onClick={goToNextPage}
disabled={!numPages || currentPage >= numPages}
className="p-2 text-gray-600 hover:text-blue-600 hover:bg-blue-50 rounded disabled:opacity-50 disabled:cursor-not-allowed"
title="다음 페이지"
>
<ChevronRight className="w-4 h-4" />
</button>
<div className="w-px h-6 bg-gray-300 mx-2"></div>
{/* 줌 컨트롤 */}
<button
onClick={zoomOut}
className="p-2 text-gray-600 hover:text-blue-600 hover:bg-blue-50 rounded"
title="축소"
>
<ZoomOut className="w-4 h-4" />
</button>
<span className="text-sm text-gray-700 px-2">
{Math.round(scale * 100)}%
</span>
<button
onClick={zoomIn}
className="p-2 text-gray-600 hover:text-blue-600 hover:bg-blue-50 rounded"
title="확대"
>
<ZoomIn className="w-4 h-4" />
</button>
<div className="w-px h-6 bg-gray-300 mx-2"></div>
{/* 회전 */}
<button
onClick={rotate}
className="p-2 text-gray-600 hover:text-blue-600 hover:bg-blue-50 rounded"
title="회전"
>
<RotateCw className="w-4 h-4" />
</button>
<div className="w-px h-6 bg-gray-300 mx-2"></div>
{/* 연속 페이지 모드 토글 */}
<button
onClick={() => setShowContinuousPages(!showContinuousPages)}
className={`p-2 rounded transition-colors ${
showContinuousPages
? 'text-blue-600 bg-blue-50'
: 'text-gray-600 hover:text-blue-600 hover:bg-blue-50'
}`}
title={showContinuousPages ? "단일 페이지 모드" : "연속 페이지 모드"}
>
<div className="w-4 h-4 flex flex-col space-y-0.5">
<div className="w-full h-0.5 bg-current"></div>
<div className="w-full h-0.5 bg-current"></div>
<div className="w-full h-0.5 bg-current"></div>
</div>
</button>
</div>
<div className="flex items-center space-x-2">
{/* 파일명 */}
<span className="text-sm text-gray-700 truncate max-w-[300px]">
{filename}
</span>
<div className="w-px h-6 bg-gray-300 mx-2"></div>
{/* 사용법 안내 */}
<div className="text-xs text-gray-500 px-2">
{showContinuousPages
? "연속 페이지 모드 - 화살표키로 페이지 이동"
: "단일 페이지 모드 - 화살표키로 페이지 이동"
}
</div>
<div className="w-px h-6 bg-gray-300 mx-2"></div>
{/* 액션 버튼들 제거됨 */}
<button
onClick={onClose}
className="p-2 text-gray-600 hover:text-red-600 hover:bg-red-50 rounded"
title="닫기"
>
<X className="w-4 h-4" />
</button>
</div>
</div>
{/* PDF 컨텐츠 */}
<div className="pdf-viewer-container flex-1 relative bg-gray-100 overflow-auto">
{isLoading && (
<div className="absolute inset-0 flex items-center justify-center bg-gray-100">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-4 border-blue-500 border-t-transparent mx-auto mb-4"></div>
<p className="text-lg font-medium text-gray-700">PDF를 ...</p>
<p className="text-sm text-gray-500 mt-2"> ...</p>
</div>
</div>
)}
{error && (
<div className="absolute inset-0 flex items-center justify-center bg-gray-100">
<div className="text-center">
<div className="text-red-500 text-6xl mb-4"></div>
<p className="text-red-600 text-lg font-medium">{error}</p>
<p className="text-gray-500 mt-2"> .</p>
</div>
</div>
)}
{pdfUrl && !isLoading && !error && (
<div className="flex justify-center p-4">
<Document
file={pdfUrl}
onLoadSuccess={onDocumentLoadSuccess}
onLoadError={onDocumentLoadError}
loading={
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-4 border-blue-500 border-t-transparent mx-auto mb-2"></div>
<p className="text-sm text-gray-600"> ...</p>
</div>
}
>
{showContinuousPages ? (
// 연속 페이지 모드: 현재 페이지가 위쪽에 크게, 다음 페이지가 아래쪽에 작게 표시
<div className="space-y-4">
{/* 현재 페이지 (위쪽에 크게) */}
<div className="flex justify-center">
<div className="text-center">
<div className="text-xs text-gray-500 mb-2"> {currentPage}</div>
<Page
pageNumber={currentPage}
scale={scale}
rotate={rotation}
className="shadow-lg"
/>
</div>
</div>
{/* 다음 페이지 (아래쪽에 작게) */}
{numPages && currentPage < numPages && (
<div className="flex justify-center">
<div className="text-center">
<div className="text-xs text-gray-500 mb-2"> {currentPage + 1}</div>
<Page
pageNumber={currentPage + 1}
scale={scale * 0.7}
rotate={rotation}
className="shadow-md opacity-60"
/>
</div>
</div>
)}
</div>
) : (
// 단일 페이지 모드
<Page
pageNumber={currentPage}
scale={scale}
rotate={rotation}
className="shadow-lg"
/>
)}
</Document>
</div>
)}
</div>
</motion.div>
</motion.div>
</AnimatePresence>
);
};
export default PDFViewer;

View File

@ -0,0 +1,35 @@
import React from 'react';
import { motion } from 'framer-motion';
const TypingIndicator: React.FC = () => {
return (
<div className="flex justify-start">
<div className="flex items-center space-x-3">
<div className="w-8 h-8 rounded-full bg-gradient-to-r from-gray-500 to-gray-600 flex items-center justify-center">
<div className="w-4 h-4 bg-white rounded-full"></div>
</div>
<div className="bg-gray-100 rounded-2xl px-4 py-3">
<div className="flex space-x-1">
<motion.div
className="w-2 h-2 bg-gray-400 rounded-full"
animate={{ scale: [1, 1.2, 1] }}
transition={{ duration: 0.6, repeat: Infinity, delay: 0 }}
/>
<motion.div
className="w-2 h-2 bg-gray-400 rounded-full"
animate={{ scale: [1, 1.2, 1] }}
transition={{ duration: 0.6, repeat: Infinity, delay: 0.2 }}
/>
<motion.div
className="w-2 h-2 bg-gray-400 rounded-full"
animate={{ scale: [1, 1.2, 1] }}
transition={{ duration: 0.6, repeat: Infinity, delay: 0.4 }}
/>
</div>
</div>
</div>
</div>
);
};
export default TypingIndicator;

View File

@ -0,0 +1,122 @@
import React, { createContext, useContext, useState, useEffect } from 'react';
interface AuthContextType {
isAuthenticated: boolean;
login: (username: string, password: string) => Promise<boolean>;
logout: () => void;
token: string | null;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export const useAuth = () => {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
};
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [token, setToken] = useState<string | null>(null);
useEffect(() => {
const checkTokenValidity = () => {
console.log('🔍 인증 토큰 유효성 검사 시작');
const savedToken = localStorage.getItem('auth_token');
const tokenExpiry = localStorage.getItem('auth_token_expiry');
console.log('📋 저장된 토큰 존재:', !!savedToken);
console.log('📋 토큰 만료 시간:', tokenExpiry);
if (savedToken && tokenExpiry) {
const now = new Date().getTime();
const expiryTime = parseInt(tokenExpiry);
console.log('📋 현재 시간:', now);
console.log('📋 만료 시간:', expiryTime);
console.log('📋 시간 차이:', expiryTime - now);
if (now < expiryTime) {
// 토큰이 아직 유효함
setToken(savedToken);
setIsAuthenticated(true);
console.log('✅ 토큰이 유효합니다.');
} else {
// 토큰이 만료됨
console.log('❌ 토큰이 만료되었습니다. 로그인이 필요합니다.');
console.log('⏰ 로그인 권한이 만료되었습니다. 다시 로그인해주세요.');
setToken(null);
setIsAuthenticated(false);
localStorage.removeItem('auth_token');
localStorage.removeItem('auth_token_expiry');
}
} else {
console.log('📋 저장된 토큰이 없습니다.');
}
};
// 초기 토큰 체크
checkTokenValidity();
// 1분마다 토큰 만료 체크 (더 정확한 만료 시간 관리)
const interval = setInterval(checkTokenValidity, 1 * 60 * 1000);
return () => clearInterval(interval);
}, []);
const login = async (username: string, password: string): Promise<boolean> => {
try {
console.log('🔐 로그인 시도 시작');
console.log('📋 사용자명:', username);
console.log('📋 비밀번호 존재:', !!password);
// 하드코딩된 인증
const validUsername = 'admin';
const validPassword = 'researchqa';
if (username === validUsername && password === validPassword) {
console.log('✅ 로그인 성공');
const fakeToken = 'hardcoded_auth_token_' + Date.now();
setToken(fakeToken);
setIsAuthenticated(true);
// 토큰과 만기 시간 저장 (1시간 후)
const expiryTime = new Date().getTime() + (60 * 60 * 1000); // 1시간 = 60분 * 60초 * 1000ms
localStorage.setItem('auth_token', fakeToken);
localStorage.setItem('auth_token_expiry', expiryTime.toString());
console.log('💾 토큰 저장 완료');
console.log('📋 만료 시간:', new Date(expiryTime).toLocaleString());
console.log('⏰ 로그인 권한이 1시간 동안 유지됩니다.');
return true;
} else {
console.log('❌ 로그인 실패 - 잘못된 자격 증명');
return false;
}
} catch (error) {
console.error('❌ 로그인 네트워크 오류');
console.error('📋 오류 타입:', error instanceof Error ? error.name : typeof error);
console.error('📋 오류 메시지:', error instanceof Error ? error.message : String(error));
console.error('📋 전체 오류:', error);
return false;
}
};
const logout = () => {
console.log('🚪 로그아웃 시작');
setToken(null);
setIsAuthenticated(false);
localStorage.removeItem('auth_token');
localStorage.removeItem('auth_token_expiry');
console.log('✅ 로그아웃 완료');
};
return (
<AuthContext.Provider value={{ isAuthenticated, login, logout, token }}>
{children}
</AuthContext.Provider>
);
};

View File

@ -0,0 +1,51 @@
import React, { createContext, useContext, useState } from 'react';
export interface Message {
id: string;
content: string;
isUser: boolean;
timestamp: Date;
sources?: string[];
}
interface ChatContextType {
messages: Message[];
addMessage: (message: Omit<Message, 'id' | 'timestamp'>) => void;
clearMessages: () => void;
isLoading: boolean;
setIsLoading: (loading: boolean) => void;
}
const ChatContext = createContext<ChatContextType | undefined>(undefined);
export const useChat = () => {
const context = useContext(ChatContext);
if (context === undefined) {
throw new Error('useChat must be used within a ChatProvider');
}
return context;
};
export const ChatProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [messages, setMessages] = useState<Message[]>([]);
const [isLoading, setIsLoading] = useState(false);
const addMessage = (message: Omit<Message, 'id' | 'timestamp'>) => {
const newMessage: Message = {
...message,
id: Date.now().toString(),
timestamp: new Date(),
};
setMessages(prev => [...prev, newMessage]);
};
const clearMessages = () => {
setMessages([]);
};
return (
<ChatContext.Provider value={{ messages, addMessage, clearMessages, isLoading, setIsLoading }}>
{children}
</ChatContext.Provider>
);
};

View File

@ -0,0 +1,266 @@
import React, { createContext, useContext, useState, useEffect } from 'react';
export interface FileInfo {
id: string;
filename: string;
upload_date: string;
file_type: string;
status: string;
}
interface FileContextType {
files: FileInfo[];
uploadFile: (file: File) => Promise<boolean>;
uploadMultipleFiles: (files: FileList) => Promise<{success: number, error: number, results: any[]}>;
deleteFile: (fileId: string) => Promise<boolean>;
refreshFiles: () => Promise<void>;
searchFiles: (searchTerm: string) => Promise<void>;
isLoading: boolean;
}
const FileContext = createContext<FileContextType | undefined>(undefined);
export const useFiles = () => {
const context = useContext(FileContext);
if (context === undefined) {
throw new Error('useFiles must be used within a FileProvider');
}
return context;
};
export const FileProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [files, setFiles] = useState<FileInfo[]>([]);
const [isLoading, setIsLoading] = useState(false);
const fetchFiles = async (searchTerm?: string) => {
try {
console.log('📁 파일 목록 조회 시작');
let url = 'http://localhost:8000/files';
console.log(`📋 검색어: '${searchTerm}'`);
// 검색어가 있으면 쿼리 파라미터 추가
if (searchTerm && searchTerm.trim()) {
const encodedSearch = encodeURIComponent(searchTerm.trim());
url += `?search=${encodedSearch}`;
console.log(`🔍 검색 URL: ${url}`);
console.log(`📋 원본 검색어: '${searchTerm}'`);
console.log(`📋 인코딩된 검색어: '${encodedSearch}'`);
}
console.log(`📤 최종 요청 URL: ${url}`);
// 인증 없이 요청 (파일 목록은 누구나 조회 가능)
console.log('📋 인증 없이 파일 목록 요청합니다.');
const response = await fetch(url);
console.log(`📥 응답 받음: ${response.status} ${response.statusText}`);
if (response.ok) {
console.log('✅ 파일 조회 성공');
const data = await response.json();
const filesArray = data.files || []; // Extract files array
setFiles(filesArray);
console.log(`📁 파일 조회 완료: ${filesArray.length}개 (검색어: ${searchTerm || '전체'})`);
console.log(`📋 반환된 파일들:`, filesArray.map((f: FileInfo) => f.filename));
} else {
console.error('❌ 파일 조회 실패');
console.error(`📋 상태 코드: ${response.status} ${response.statusText}`);
const errorText = await response.text();
console.error(`📋 오류 내용: ${errorText}`);
}
} catch (error) {
console.error('❌ 파일 목록 조회 네트워크 오류');
console.error('📋 오류 타입:', error instanceof Error ? error.name : typeof error);
console.error('📋 오류 메시지:', error instanceof Error ? error.message : String(error));
console.error('📋 전체 오류:', error);
}
};
const searchFiles = async (searchTerm: string) => {
console.log('🔍 서버 검색 실행:', searchTerm);
await fetchFiles(searchTerm);
};
const uploadFile = async (file: File): Promise<boolean> => {
try {
console.log('📤 파일 업로드 시작');
console.log('📋 파일명:', file.name);
console.log('📋 파일 크기:', file.size, 'bytes');
console.log('📋 파일 타입:', file.type);
const token = localStorage.getItem('auth_token');
// 파일 업로드는 로그인이 필요함
if (!token) {
console.log('🔒 파일 업로드를 위해서는 로그인이 필요합니다.');
alert('파일 업로드를 위해서는 로그인이 필요합니다.');
return false;
}
setIsLoading(true);
const formData = new FormData();
formData.append('file', file);
console.log('📤 업로드 요청 전송 중...');
console.log('📋 요청 URL: http://localhost:8000/upload');
console.log('📋 인증 토큰 존재:', !!token);
const response = await fetch('http://localhost:8000/upload', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
},
body: formData,
});
console.log('📥 업로드 응답 받음');
console.log('📋 응답 상태:', response.status, response.statusText);
if (response.ok) {
console.log('✅ 파일 업로드 성공');
// 업로드 성공 후 파일 목록 새로고침
await new Promise(resolve => setTimeout(resolve, 1000)); // 1초 대기
await fetchFiles();
console.log('📁 파일 목록 새로고침 완료');
return true;
} else {
console.log('❌ 파일 업로드 실패');
const errorData = await response.json();
console.error('📋 오류 데이터:', errorData);
alert(`파일 업로드 실패: ${errorData.detail || '알 수 없는 오류가 발생했습니다.'}`);
return false;
}
} catch (error) {
console.error('❌ 파일 업로드 네트워크 오류');
console.error('📋 오류 타입:', error instanceof Error ? error.name : typeof error);
console.error('📋 오류 메시지:', error instanceof Error ? error.message : String(error));
console.error('📋 전체 오류:', error);
const errorMessage = error instanceof Error ? error.message : '네트워크 오류가 발생했습니다.';
alert(`파일 업로드 실패: ${errorMessage}`);
return false;
} finally {
console.log('🏁 파일 업로드 완료');
setIsLoading(false);
}
};
const uploadMultipleFiles = async (files: FileList): Promise<{success: number, error: number, results: any[]}> => {
try {
console.log('멀티파일 업로드 시작:', files.length, '개 파일');
setIsLoading(true);
const token = localStorage.getItem('auth_token');
const formData = new FormData();
// 모든 파일을 FormData에 추가
for (let i = 0; i < files.length; i++) {
formData.append('files', files[i]);
}
console.log('멀티파일 업로드 요청 전송 중...');
const response = await fetch('http://localhost:8000/upload-multiple', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
},
body: formData,
});
console.log('멀티파일 업로드 응답 받음:', response.status, response.statusText);
if (response.ok) {
const result = await response.json();
console.log('멀티파일 업로드 결과:', result);
// 업로드 성공 후 파일 목록 새로고침
await fetchFiles();
return {
success: result.success_count,
error: result.error_count,
results: result.results
};
} else {
console.error('멀티파일 업로드 실패:', response.status, response.statusText);
const errorText = await response.text();
console.error('오류 내용:', errorText);
return {success: 0, error: files.length, results: []};
}
} catch (error) {
console.error('멀티파일 업로드 중 오류 발생:', error);
return {success: 0, error: files.length, results: []};
} finally {
setIsLoading(false);
}
};
const deleteFile = async (fileId: string): Promise<boolean> => {
try {
console.log('🗑️ 파일 삭제 시작');
console.log('📋 파일 ID:', fileId);
const token = localStorage.getItem('auth_token');
// 파일 삭제는 로그인이 필요함
if (!token) {
console.log('🔒 파일 삭제를 위해서는 로그인이 필요합니다.');
alert('파일 삭제를 위해서는 로그인이 필요합니다.');
return false;
}
console.log('📤 삭제 요청 전송 중...');
console.log('📋 요청 URL:', `http://localhost:8000/files/${fileId}`);
console.log('📋 인증 토큰 존재:', !!token);
const response = await fetch(`http://localhost:8000/files/${fileId}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${token}`,
},
});
console.log('📥 삭제 응답 받음');
console.log('📋 응답 상태:', response.status, response.statusText);
if (response.ok) {
console.log('✅ 파일 삭제 성공');
console.log('📁 목록 새로고침 중...');
// 즉시 UI 업데이트를 위해 파일 목록에서 해당 파일 제거
setFiles(prevFiles => prevFiles.filter(file => file.id !== fileId));
// 백그라운드에서 파일 목록 새로고침
fetchFiles();
console.log('📁 파일 목록 새로고침 완료');
return true;
} else {
console.log('❌ 파일 삭제 실패');
const errorData = await response.json();
console.error('📋 오류 데이터:', errorData);
return false;
}
} catch (error) {
console.error('❌ 파일 삭제 네트워크 오류');
console.error('📋 오류 타입:', error instanceof Error ? error.name : typeof error);
console.error('📋 오류 메시지:', error instanceof Error ? error.message : String(error));
console.error('📋 전체 오류:', error);
return false;
}
};
const refreshFiles = async () => {
console.log('🔄 파일 목록 새로고침 시작');
await fetchFiles();
console.log('✅ 파일 목록 새로고침 완료');
};
useEffect(() => {
console.log('🚀 FileContext 초기화 - 파일 목록 조회 시작');
fetchFiles();
}, []);
return (
<FileContext.Provider value={{ files, uploadFile, uploadMultipleFiles, deleteFile, refreshFiles, searchFiles, isLoading }}>
{children}
</FileContext.Provider>
);
};

166
frontend/src/index.css Normal file
View File

@ -0,0 +1,166 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}
/* 스크롤바 스타일링 */
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 3px;
}
::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
}
/* 채팅 메시지 애니메이션 */
.message-enter {
opacity: 0;
transform: translateY(20px);
}
.message-enter-active {
opacity: 1;
transform: translateY(0);
transition: opacity 300ms, transform 300ms;
}
/* 로딩 애니메이션 */
.typing-indicator {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
background-color: #9ca3af;
animation: typing 1.4s infinite ease-in-out;
}
.typing-indicator:nth-child(1) {
animation-delay: -0.32s;
}
.typing-indicator:nth-child(2) {
animation-delay: -0.16s;
}
@keyframes typing {
0%, 80%, 100% {
transform: scale(0);
opacity: 0.5;
}
40% {
transform: scale(1);
opacity: 1;
}
}
/* 그라데이션 텍스트 */
.gradient-text {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
/* 카드 호버 효과 */
.card-hover {
transition: all 0.3s ease;
}
.card-hover:hover {
transform: translateY(-2px);
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
}
/* 버튼 애니메이션 */
.btn-animate {
transition: all 0.2s ease;
}
.btn-animate:hover {
transform: translateY(-1px);
}
.btn-animate:active {
transform: translateY(0);
}
/* PDF 뷰어 스타일 */
.react-pdf__Page {
display: flex;
flex-direction: column;
align-items: center;
}
.react-pdf__Page__canvas {
display: block;
max-width: 100%;
max-height: 100%;
}
.react-pdf__Page__textContent {
position: absolute;
top: 0;
left: 0;
transform: scale(1);
transform-origin: 0 0;
white-space: pre;
cursor: text;
color: transparent;
font-family: sans-serif;
overflow: hidden;
user-select: text;
}
.react-pdf__Page__textContent span {
color: transparent;
position: absolute;
white-space: pre;
cursor: text;
transform-origin: 0% 0%;
}
.react-pdf__Page__annotations {
position: absolute;
top: 0;
left: 0;
transform: scale(1);
transform-origin: 0 0;
}
.react-pdf__Page__annotation {
position: absolute;
transform: scale(1);
transform-origin: 0% 0%;
}

13
frontend/src/index.tsx Normal file
View File

@ -0,0 +1,13 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement
);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);

View File

@ -0,0 +1,56 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
"./src/**/*.{js,jsx,ts,tsx}",
],
theme: {
extend: {
colors: {
primary: {
50: '#f0f9ff',
100: '#e0f2fe',
200: '#bae6fd',
300: '#7dd3fc',
400: '#38bdf8',
500: '#0ea5e9',
600: '#0284c7',
700: '#0369a1',
800: '#075985',
900: '#0c4a6e',
},
gray: {
50: '#f9fafb',
100: '#f3f4f6',
200: '#e5e7eb',
300: '#d1d5db',
400: '#9ca3af',
500: '#6b7280',
600: '#4b5563',
700: '#374151',
800: '#1f2937',
900: '#111827',
}
},
animation: {
'fade-in': 'fadeIn 0.5s ease-in-out',
'slide-up': 'slideUp 0.3s ease-out',
'bounce-gentle': 'bounceGentle 2s infinite',
},
keyframes: {
fadeIn: {
'0%': { opacity: '0' },
'100%': { opacity: '1' },
},
slideUp: {
'0%': { transform: 'translateY(10px)', opacity: '0' },
'100%': { transform: 'translateY(0)', opacity: '1' },
},
bounceGentle: {
'0%, 100%': { transform: 'translateY(0)' },
'50%': { transform: 'translateY(-5px)' },
}
}
},
},
plugins: [],
}

26
frontend/tsconfig.json Normal file
View File

@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "es5",
"lib": [
"dom",
"dom.iterable",
"es6"
],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": [
"src"
]
}

17
start_backend.sh Executable file
View File

@ -0,0 +1,17 @@
#!/bin/bash
echo "연구QA Chatbot 백엔드 서버를 시작합니다..."
# 백엔드 디렉토리로 이동
cd backend
# Python 가상환경 활성화 (선택사항)
# source venv/bin/activate
# 의존성 설치
echo "Python 의존성을 설치합니다..."
pip install -r requirements.txt
# 백엔드 서버 시작
echo "백엔드 서버를 시작합니다..."
python main.py

14
start_frontend.sh Executable file
View File

@ -0,0 +1,14 @@
#!/bin/bash
echo "연구QA Chatbot 프론트엔드를 시작합니다..."
# 프론트엔드 디렉토리로 이동
cd frontend
# Node.js 의존성 설치
echo "Node.js 의존성을 설치합니다..."
npm install
# 프론트엔드 개발 서버 시작
echo "프론트엔드 개발 서버를 시작합니다..."
npm start