From 94e8857f445a551ed4b9e3851191c86b24ac25a7 Mon Sep 17 00:00:00 2001 From: dsyoon Date: Thu, 9 Oct 2025 14:31:51 +0900 Subject: [PATCH] init --- backend/services/langchain_service.py | 159 +++++++-- frontend/package.json | 23 +- frontend/src/App.tsx | 33 ++ frontend/src/components/FileUploadModal.tsx | 11 +- frontend/src/components/MessageBubble.tsx | 73 ++++- frontend/src/components/TypingIndicator.tsx | 8 +- frontend/src/index.css | 342 ++++++++++++++++++++ 7 files changed, 605 insertions(+), 44 deletions(-) diff --git a/backend/services/langchain_service.py b/backend/services/langchain_service.py index c7cdfe5..006a51b 100644 --- a/backend/services/langchain_service.py +++ b/backend/services/langchain_service.py @@ -92,17 +92,29 @@ class LangChainRAGService: def _setup_rag_chain(self): """RAG 체인 설정""" try: - # 프롬프트 템플릿 + # 개선된 프롬프트 템플릿 prompt_template = """ - 다음 문서들을 참고하여 질문에 답변해주세요. - - 문서들: - {context} - - 질문: {input} - - 답변: 문서의 내용을 바탕으로 정확하고 상세하게 답변해주세요. - """ + 당신은 연구 문서 전문가입니다. 주어진 문서들을 바탕으로 사용자의 질문에 정확하고 상세하게 답변해주세요. + + **절대 지켜야 할 답변 규칙:** + 1. 답변은 반드시 한국어로만 시작하세요 + 2. 영어 단어나 문장을 절대 사용하지 마세요 + 3. 부터 까지의 모든 내용을 절대 포함하지 마세요 + 4. 사고 과정, 분석 과정, 고민 과정을 전혀 보여주지 마세요 + 5. 바로 최종 답변만 제공하세요 + 6. 문서의 내용을 정확히 파악하고 요약하여 답변하세요 + 7. 구체적인 절차나 방법이 있다면 단계별로 설명하세요 + 8. 중요한 정보나 주의사항이 있다면 강조하세요 + 9. 문서에 없는 내용은 추측하지 말고 "문서에 명시되지 않음"이라고 하세요 + 10. 마크다운 형식을 사용하여 구조화된 답변을 제공하세요 + + **참조 문서:** + {context} + + **사용자 질문:** {input} + + **답변:** 위 문서의 내용을 바탕으로 질문에 대한 구체적이고 실용적인 답변을 마크다운 형식으로 바로 제공해주세요. 반드시 한국어로 시작하고, 영어나 태그는 절대 포함하지 마세요. + """ prompt = PromptTemplate( template=prompt_template, @@ -171,33 +183,133 @@ class LangChainRAGService: def generate_answer(self, question: str) -> Dict[str, Any]: """RAG를 통한 답변 생성""" try: - # 간단한 유사 문서 검색으로 시작 + # RAG 체인을 사용하여 답변 생성 + if self.qa_chain: + logger.info(f"🤖 LLM을 사용한 RAG 답변 생성 시작: {question}") + result = self.qa_chain.invoke({"input": question}) + + # 참조 문서 정보 추출 + references = [] + source_documents = result.get("context", []) + + for doc in source_documents: + 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}]") + + # 태그 및 영어 사고 과정 제거 (강화된 버전) + answer = result.get("answer", "답변을 생성할 수 없습니다.") + import re + + # 부터 까지의 모든 내용 제거 (대소문자 구분 없음) + answer = re.sub(r'.*?', '', answer, flags=re.DOTALL | re.IGNORECASE).strip() + # 태그만 있는 경우도 제거 + answer = re.sub(r'', '', answer, flags=re.IGNORECASE).strip() + # 태그만 있는 경우도 제거 + answer = re.sub(r'', '', answer, flags=re.IGNORECASE).strip() + + # 영어 사고 과정 완전 제거 (극강화 버전) + lines = answer.split('\n') + filtered_lines = [] + korean_found = False + + for line in lines: + line = line.strip() + if not line: + filtered_lines.append(line) + continue + + # 한국어가 포함된 줄이 나오면 korean_found = True + if re.search(r'[가-힣]', line): + korean_found = True + + # 한국어가 발견되기 전까지는 모든 영어 줄 무조건 제거 + if not korean_found: + if re.match(r'^[A-Za-z]', line): + continue + + # 한국어가 발견된 후에도 영어 사고 과정 제거 + if re.match(r'^[A-Za-z]', line): + # 모든 영어 패턴 제거 (더 많은 패턴 추가) + if any(phrase in line.lower() for phrase in [ + 'let me', 'i need to', 'i should', 'i will', 'i can', + 'the answer should', 'make sure', 'avoid any', + 'structure the answer', 'break down', 'organize', + 'tag or any thinking', 'so i need to focus', + 'focus on the main content', 'documents', + 'thinking process', 'internal thought', + 'i need to focus', 'main content', 'tag', 'thinking', + 'tag or any', 'so i need', 'focus on', 'main content of', + 'documents', 'thinking', 'process', 'internal' + ]): + continue + # 영어 문장이지만 중요한 내용이 아닌 경우 제거 + if len(line) > 10 and any(word in line.lower() for word in [ + 'thinking', 'process', 'structure', 'format', 'markdown', + 'tag', 'focus', 'content', 'documents', 'internal', + 'answer', 'should', 'make', 'sure', 'avoid' + ]): + continue + # 짧은 영어 문장도 제거 (사고 과정일 가능성) + if len(line) < 200 and not re.search(r'[가-힣]', line): + continue + + filtered_lines.append(line) + + answer = '\n'.join(filtered_lines).strip() + + response = { + "answer": answer, + "references": references, + "source_documents": source_documents + } + + logger.info(f"✅ LLM RAG 답변 생성 완료: {len(references)}개 참조") + return response + else: + # RAG 체인이 없는 경우 폴백 + logger.warning("⚠️ RAG 체인이 초기화되지 않음. 폴백 모드로 전환") + return self._generate_fallback_answer(question) + + except Exception as e: + logger.error(f"❌ RAG 답변 생성 실패: {e}") + # 오류 시 폴백 답변 생성 + return self._generate_fallback_answer(question) + + def _generate_fallback_answer(self, question: str) -> Dict[str, Any]: + """폴백 답변 생성 (LLM 없이)""" + try: + # 유사 문서 검색 similar_docs = self.search_similar_documents(question, k=3) if not similar_docs: return { - "answer": "죄송합니다. 관련 문서를 찾을 수 없습니다.", - "references": ["문서 없음"], + "answer": "죄송합니다. 관련 문서를 찾을 수 없습니다. 다른 키워드로 검색해보시거나 문서를 업로드해주세요.", + "references": [], "source_documents": [] } - # 문서 내용을 기반으로 간단한 답변 생성 - context_text = "" + # 문서 내용 요약 + context_summary = "" references = [] for i, doc in enumerate(similar_docs): - context_text += f"\n문서 {i+1}:\n{doc.page_content[:500]}...\n" + # 문서 내용의 핵심 부분 추출 (처음 300자) + content_preview = doc.page_content[:300].replace('\n', ' ').strip() + context_summary += f"\n• {content_preview}...\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참조 문서에서 관련 내용을 확인할 수 있습니다." + # 간단한 답변 생성 + answer = f"질문하신 '{question}'에 대한 관련 문서를 찾았습니다.\n\n참조 문서에서 관련 내용을 확인할 수 있습니다:\n{context_summary}" response = { "answer": answer, @@ -205,15 +317,14 @@ class LangChainRAGService: "source_documents": similar_docs } - logger.info(f"✅ RAG 답변 생성 완료: {len(references)}개 참조") + logger.info(f"✅ 폴백 답변 생성 완료: {len(references)}개 참조") return response except Exception as e: - logger.error(f"❌ RAG 답변 생성 실패: {e}") - # 오류 시 기본 응답 반환 + logger.error(f"❌ 폴백 답변 생성 실패: {e}") return { - "answer": "죄송합니다. 현재 시스템 오류로 인해 답변을 생성할 수 없습니다.", - "references": ["시스템 오류"], + "answer": "죄송합니다. 현재 시스템 오류로 인해 답변을 생성할 수 없습니다. 잠시 후 다시 시도해주세요.", + "references": [], "source_documents": [] } diff --git a/frontend/package.json b/frontend/package.json index 395016d..3f069d7 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -3,20 +3,27 @@ "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/node": "^20.0.0", "@types/react": "^18.2.0", "@types/react-dom": "^18.2.0", - "@types/node": "^20.0.0", + "autoprefixer": "^10.4.0", "framer-motion": "^10.16.0", "lucide-react": "^0.294.0", - "react-pdf": "^10.1.0", "pdfjs-dist": "^5.3.93", + "postcss": "^8.4.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-markdown": "^10.1.0", + "react-pdf": "^10.1.0", + "react-scripts": "5.0.1", + "rehype-highlight": "^7.0.2", + "rehype-katex": "^7.0.1", + "rehype-raw": "^7.0.0", + "remark-breaks": "^4.0.0", + "remark-gfm": "^4.0.1", + "remark-math": "^6.0.0", "tailwindcss": "^3.3.0", - "autoprefixer": "^10.4.0", - "postcss": "^8.4.0" + "typescript": "^4.9.5" }, "scripts": { "start": "react-scripts start", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1fdb684..4539476 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -3,6 +3,7 @@ import { motion, AnimatePresence } from 'framer-motion'; import ChatInterface from './components/ChatInterface'; import LoginModal from './components/LoginModal'; import FileUploadModal from './components/FileUploadModal'; +import PDFViewer from './components/PDFViewer'; import { AuthProvider, useAuth } from './contexts/AuthContext'; import { ChatProvider } from './contexts/ChatContext'; import { FileProvider } from './contexts/FileContext'; @@ -12,6 +13,12 @@ function AppContent() { const { isAuthenticated } = useAuth(); const [showLogin, setShowLogin] = useState(false); const [showFileUpload, setShowFileUpload] = useState(false); + const [showPDFViewer, setShowPDFViewer] = useState(false); + const [pdfViewerData, setPdfViewerData] = useState<{ + fileId: string; + filename: string; + pageNumber: number; + } | null>(null); const handleFileUploadClick = () => { @@ -25,6 +32,22 @@ function AppContent() { } }; + const handlePDFView = (fileId: string, filename: string) => { + setPdfViewerData({ + fileId, + filename, + pageNumber: 1 + }); + setShowPDFViewer(true); + setShowFileUpload(false); // 파일 업로드 모달 닫기 + }; + + const handlePDFViewerClose = () => { + setShowPDFViewer(false); + setPdfViewerData(null); + setShowFileUpload(true); // 파일 업로드 모달 다시 열기 + }; + return (
{/* 헤더 */} @@ -77,6 +100,16 @@ function AppContent() { {showFileUpload && ( setShowFileUpload(false)} + onPDFView={handlePDFView} + /> + )} + + {showPDFViewer && pdfViewerData && ( + )} diff --git a/frontend/src/components/FileUploadModal.tsx b/frontend/src/components/FileUploadModal.tsx index 1c92a37..d9dfeed 100644 --- a/frontend/src/components/FileUploadModal.tsx +++ b/frontend/src/components/FileUploadModal.tsx @@ -5,9 +5,10 @@ import { X, Upload, Trash2, Search } from 'lucide-react'; interface FileUploadModalProps { onClose: () => void; + onPDFView: (fileId: string, filename: string) => void; } -const FileUploadModal: React.FC = ({ onClose }) => { +const FileUploadModal: React.FC = ({ onClose, onPDFView }) => { const { files, uploadFile, deleteFile, refreshFiles, searchFiles, isLoading } = useFiles(); const [dragActive, setDragActive] = useState(false); const [searchTerm, setSearchTerm] = useState(''); @@ -252,6 +253,10 @@ const FileUploadModal: React.FC = ({ onClose }) => { } }; + const handleDocumentDoubleClick = (fileId: string, filename: string) => { + onPDFView(fileId, filename); + }; + const showTooltip = (content: string, e: React.MouseEvent) => { setTooltip({ show: true, @@ -483,10 +488,12 @@ const FileUploadModal: React.FC = ({ onClose }) => { {file.id}
showTooltip(file.filename, e)} onMouseLeave={hideTooltip} onMouseMove={(e) => showTooltip(file.filename, e)} + onDoubleClick={() => handleDocumentDoubleClick(file.id, file.filename)} + title="더블클릭하여 PDF 뷰어로 열기" > {file.filename}
diff --git a/frontend/src/components/MessageBubble.tsx b/frontend/src/components/MessageBubble.tsx index e5bfda8..908e3ee 100644 --- a/frontend/src/components/MessageBubble.tsx +++ b/frontend/src/components/MessageBubble.tsx @@ -1,6 +1,9 @@ import React, { useState } from 'react'; import { motion } from 'framer-motion'; -import { User, Bot, FileText } from 'lucide-react'; +import { User, FileText } from 'lucide-react'; +import ReactMarkdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; +import remarkBreaks from 'remark-breaks'; import { Message } from '../contexts/ChatContext'; import PDFViewer from './PDFViewer'; @@ -88,28 +91,82 @@ const MessageBubble: React.FC = ({ message }) => {
{isUser ? ( ) : ( - + 연구QA 챗봇 )}
{/* 메시지 내용 */} -
-
- {message.content} -
+ {isUser ? ( +
+ {message.content} +
+ ) : ( +
+
{children}
, + // code 태그 처리 + code: ({ children, className }) => { + const isInline = !className; + return isInline ? ( + {children} + ) : ( +
{children}
+ ); + }, + // 모든 마크다운 요소에 대한 커스텀 스타일링 + h1: ({ children }) =>

{children}

, + h2: ({ children }) =>

{children}

, + h3: ({ children }) =>

{children}

, + h4: ({ children }) =>

{children}

, + p: ({ children }) =>

{children}

, + ul: ({ children }) =>
    {children}
, + ol: ({ children }) =>
    {children}
, + li: ({ children }) =>
  • {children}
  • , + strong: ({ children }) => {children}, + em: ({ children }) => {children}, + blockquote: ({ children }) =>
    {children}
    , + table: ({ children }) =>
    {children}
    , + thead: ({ children }) => {children}, + tbody: ({ children }) => {children}, + tr: ({ children }) => {children}, + th: ({ children }) => {children}, + td: ({ children }) => {children}, + hr: () =>
    , + }} + > + {message.content} +
    +
    + )} {/* 소스 정보 */} {!isUser && message.sources && message.sources.length > 0 && ( -
    +
    참조 문서: diff --git a/frontend/src/components/TypingIndicator.tsx b/frontend/src/components/TypingIndicator.tsx index 99bd351..7482137 100644 --- a/frontend/src/components/TypingIndicator.tsx +++ b/frontend/src/components/TypingIndicator.tsx @@ -5,8 +5,12 @@ const TypingIndicator: React.FC = () => { return (
    -
    -
    +
    + 연구QA 챗봇
    diff --git a/frontend/src/index.css b/frontend/src/index.css index bea989d..3eb7cfc 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -164,3 +164,345 @@ code { transform: scale(1); transform-origin: 0% 0%; } + +/* 마크다운 콘텐츠 스타일 */ +.markdown-content { + max-width: 100%; + line-height: 1.6; + color: #374151; +} + +.markdown-content h1, +.markdown-content h2, +.markdown-content h3, +.markdown-content h4, +.markdown-content h5, +.markdown-content h6 { + font-weight: 600; + margin-top: 1.5rem; + margin-bottom: 0.5rem; + color: #1f2937; +} + +.markdown-content h1 { + font-size: 1.5rem; + border-bottom: 1px solid #e5e7eb; + padding-bottom: 0.5rem; +} + +.markdown-content h2 { + font-size: 1.25rem; +} + +.markdown-content h3 { + font-size: 1.125rem; +} + +.markdown-content h4 { + font-size: 1rem; +} + +.markdown-content p { + margin-bottom: 1rem; + color: #374151; +} + +.markdown-content ul, +.markdown-content ol { + margin-bottom: 1rem; + padding-left: 1.5rem; +} + +.markdown-content li { + margin-bottom: 0.25rem; + color: #374151; +} + +.markdown-content strong { + font-weight: 600; + color: #1f2937; +} + +.markdown-content em { + font-style: italic; + color: #4b5563; +} + +.markdown-content code { + background-color: #f3f4f6; + padding: 0.125rem 0.25rem; + border-radius: 0.25rem; + font-size: 0.875rem; + font-family: 'Courier New', monospace; +} + +.markdown-content pre { + background-color: #1f2937; + color: #f9fafb; + padding: 1rem; + border-radius: 0.5rem; + overflow-x: auto; + margin-bottom: 1rem; +} + +.markdown-content pre code { + background-color: transparent; + padding: 0; + color: inherit; +} + +.markdown-content blockquote { + border-left: 4px solid #3b82f6; + padding-left: 1rem; + margin: 1rem 0; + font-style: italic; + color: #4b5563; + background-color: #eff6ff; + padding: 0.5rem 1rem; + border-radius: 0 0.25rem 0.25rem 0; +} + +.markdown-content table { + width: 100%; + border-collapse: collapse; + margin-bottom: 1rem; + border: 1px solid #d1d5db; +} + +.markdown-content th, +.markdown-content td { + border: 1px solid #d1d5db; + padding: 0.5rem; + text-align: left; +} + +.markdown-content th { + background-color: #f9fafb; + font-weight: 600; + color: #1f2937; +} + +.markdown-content tr:hover { + background-color: #f9fafb; +} + +.markdown-content hr { + border: none !important; + border-top: none !important; + margin: 2rem 0; + display: none !important; /* hr 요소 완전 숨김 */ + height: 0 !important; + visibility: hidden !important; +} + +/* 모든 가능한 선 제거 */ +.markdown-content * { + border-top: none !important; +} + +.markdown-content > *:first-child { + border-top: none !important; +} + +/* 참조 문서 섹션의 모든 선 제거 */ +.mt-3.pt-3 { + border-top: none !important; + border: none !important; +} + +/* 모든 div 요소의 상단 테두리 제거 */ +div { + border-top: none !important; +} + +/* 특정 클래스의 테두리 제거 */ +.border-t { + border-top: none !important; +} + +/* 모든 가능한 선과 배경 제거 */ +* { + border-top: none !important; +} + +/* 메시지 버블의 모든 테두리 제거 */ +.rounded-2xl { + border: none !important; + border-top: none !important; + border-bottom: none !important; + border-left: none !important; + border-right: none !important; +} + +/* 마크다운 콘텐츠 스타일 */ +.markdown-content { + border: none !important; + background: transparent !important; + color: #374151 !important; + font-family: inherit !important; + line-height: 1.6 !important; + white-space: pre-wrap !important; + word-wrap: break-word !important; +} + +/* 마크다운 요소들 기본 스타일 */ +.markdown-content h1 { + font-size: 1.5rem !important; + font-weight: bold !important; + color: #374151 !important; + margin: 1rem 0 0.5rem 0 !important; + border-bottom: 2px solid #E5E7EB !important; + padding-bottom: 0.5rem !important; +} + +.markdown-content h2 { + font-size: 1.25rem !important; + font-weight: bold !important; + color: #374151 !important; + margin: 1.5rem 0 0.5rem 0 !important; +} + +.markdown-content h3 { + font-size: 1.125rem !important; + font-weight: bold !important; + color: #374151 !important; + margin: 1.25rem 0 0.5rem 0 !important; +} + +.markdown-content h4 { + font-size: 1rem !important; + font-weight: bold !important; + color: #374151 !important; + margin: 1rem 0 0.5rem 0 !important; +} + +.markdown-content p { + margin: 0.5rem 0 !important; + line-height: 1.6 !important; + color: #4B5563 !important; +} + +.markdown-content ul { + margin: 0.5rem 0 !important; + padding-left: 1.5rem !important; + list-style-type: disc !important; +} + +.markdown-content ol { + margin: 0.5rem 0 !important; + padding-left: 1.5rem !important; + list-style-type: decimal !important; +} + +.markdown-content li { + margin: 0.25rem 0 !important; + color: #4B5563 !important; +} + +.markdown-content strong { + font-weight: bold !important; + color: #374151 !important; +} + +.markdown-content em { + font-style: italic !important; + color: #4B5563 !important; +} + +.markdown-content code { + background-color: #F3F4F6 !important; + padding: 0.125rem 0.25rem !important; + border-radius: 0.25rem !important; + font-family: 'Courier New', monospace !important; + font-size: 0.875rem !important; + color: #374151 !important; +} + +/* 마크다운 내용이 pre 태그로 감싸지지 않도록 설정 */ +.markdown-content { + white-space: normal !important; + word-wrap: break-word !important; +} + +/* 전체 마크다운을 감싸는 pre 태그의 스타일을 일반 div처럼 변경 */ +.markdown-content > pre { + white-space: normal !important; + word-wrap: break-word !important; + background: transparent !important; + border: none !important; + padding: 0 !important; + margin: 0 !important; + font-family: inherit !important; + font-size: inherit !important; + line-height: inherit !important; +} + +/* 실제 코드 블록에만 적용되는 pre 태그 스타일 */ +.markdown-content pre { + background-color: #F3F4F6 !important; + color: #374151 !important; + padding: 1rem !important; + border-radius: 0.5rem !important; + overflow-x: auto !important; + margin: 1rem 0 !important; + white-space: pre-wrap !important; + word-wrap: break-word !important; +} + +/* pre 태그 내부의 코드 블록만 스타일 적용 */ +.markdown-content pre code { + background: transparent !important; + padding: 0 !important; + border-radius: 0 !important; + font-size: inherit !important; +} + +.markdown-content blockquote { + border-left: 4px solid #3B82F6 !important; + padding-left: 1rem !important; + margin: 1rem 0 !important; + font-style: italic !important; + color: #6B7280 !important; + background-color: #EFF6FF !important; + padding: 0.5rem 1rem !important; + border-radius: 0 0.25rem 0.25rem 0 !important; +} + +.markdown-content table { + width: 100% !important; + border-collapse: collapse !important; + margin: 1rem 0 !important; +} + +.markdown-content th { + border: 1px solid #D1D5DB !important; + padding: 0.5rem !important; + text-align: left !important; + background-color: #F9FAFB !important; + font-weight: bold !important; + color: #374151 !important; +} + +.markdown-content td { + border: 1px solid #D1D5DB !important; + padding: 0.5rem !important; + text-align: left !important; + color: #4B5563 !important; +} + +.markdown-content hr { + border: none !important; + border-top: 1px solid #E5E7EB !important; + margin: 2rem 0 !important; +} + +/* 참조 문서 섹션의 모든 선과 배경 제거 */ +.mt-3.pt-3 { + border: none !important; + border-top: none !important; + border-bottom: none !important; + border-left: none !important; + border-right: none !important; + background: transparent !important; + background-color: transparent !important; +}