diff --git a/backend/src/api/routes/documents.py b/backend/src/api/routes/documents.py index ee9b0da..5c597e8 100644 --- a/backend/src/api/routes/documents.py +++ b/backend/src/api/routes/documents.py @@ -507,6 +507,173 @@ async def get_document_content( raise HTTPException(status_code=500, detail=f"Error reading document: {str(e)}") +@router.get("/{document_id}/pdf") +async def get_document_pdf( + document_id: str, + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db) +): + """문서 PDF 파일 조회""" + try: + doc_uuid = UUID(document_id) + except ValueError: + raise HTTPException(status_code=400, detail="Invalid document ID format") + + # 문서 조회 + query = select(Document).where(Document.id == doc_uuid) + result = await db.execute(query) + document = result.scalar_one_or_none() + + if not document: + raise HTTPException(status_code=404, detail="Document not found") + + # 권한 확인 + if not current_user.is_admin and not document.is_public and document.uploaded_by != current_user.id: + raise HTTPException(status_code=403, detail="Access denied") + + # PDF 파일 확인 + if not document.pdf_path: + raise HTTPException(status_code=404, detail="PDF file not found for this document") + + # PDF 파일 경로 처리 + import os + from fastapi.responses import FileResponse + + if document.pdf_path.startswith('/'): + file_path = document.pdf_path + else: + file_path = os.path.join("/app/data/documents", document.pdf_path) + + if not os.path.exists(file_path): + raise HTTPException(status_code=404, detail="PDF file not found on disk") + + return FileResponse( + path=file_path, + media_type='application/pdf', + filename=f"{document.title}.pdf" + ) + + +@router.get("/{document_id}/search-in-content") +async def search_in_document_content( + document_id: str, + q: str, + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db) +): + """특정 문서 내에서 텍스트 검색 및 페이지 위치 반환""" + try: + doc_uuid = UUID(document_id) + except ValueError: + raise HTTPException(status_code=400, detail="Invalid document ID format") + + # 문서 조회 + query = select(Document).where(Document.id == doc_uuid) + result = await db.execute(query) + document = result.scalar_one_or_none() + + if not document: + raise HTTPException(status_code=404, detail="Document not found") + + # 권한 확인 + if not current_user.is_admin and not document.is_public and document.uploaded_by != current_user.id: + raise HTTPException(status_code=403, detail="Access denied") + + search_results = [] + + # HTML 파일에서 검색 (OCR 결과) + if document.html_path: + try: + import os + from bs4 import BeautifulSoup + import re + + # 절대 경로 처리 + if document.html_path.startswith('/'): + html_file_path = document.html_path + else: + html_file_path = os.path.join("/app/data/documents", document.html_path) + + if os.path.exists(html_file_path): + with open(html_file_path, 'r', encoding='utf-8') as f: + html_content = f.read() + + # HTML에서 페이지별로 검색 + soup = BeautifulSoup(html_content, 'html.parser') + + # 페이지 구분자 찾기 (OCR 결과에서 페이지 정보) + pages = soup.find_all(['div', 'section'], class_=re.compile(r'page|Page')) + + if not pages: + # 페이지 구분이 없으면 전체 텍스트에서 검색 + text_content = soup.get_text() + matches = [] + start = 0 + while True: + pos = text_content.lower().find(q.lower(), start) + if pos == -1: + break + + # 컨텍스트 추출 + context_start = max(0, pos - 100) + context_end = min(len(text_content), pos + len(q) + 100) + context = text_content[context_start:context_end] + + matches.append({ + "page": 1, + "position": pos, + "context": context, + "match_text": text_content[pos:pos + len(q)] + }) + + start = pos + 1 + if len(matches) >= 10: # 최대 10개 결과 + break + + search_results.extend(matches) + else: + # 페이지별로 검색 + for page_num, page_elem in enumerate(pages, 1): + page_text = page_elem.get_text() + matches = [] + start = 0 + + while True: + pos = page_text.lower().find(q.lower(), start) + if pos == -1: + break + + # 컨텍스트 추출 + context_start = max(0, pos - 100) + context_end = min(len(page_text), pos + len(q) + 100) + context = page_text[context_start:context_end] + + matches.append({ + "page": page_num, + "position": pos, + "context": context, + "match_text": page_text[pos:pos + len(q)] + }) + + start = pos + 1 + if len(matches) >= 5: # 페이지당 최대 5개 + break + + search_results.extend(matches) + + except Exception as e: + print(f"HTML 검색 오류: {e}") + + return { + "document_id": document_id, + "query": q, + "total_matches": len(search_results), + "matches": search_results[:20], # 최대 20개 결과 + "has_pdf": bool(document.pdf_path), + "has_html": bool(document.html_path) + } + + class UpdateDocumentRequest(BaseModel): """문서 업데이트 요청""" title: Optional[str] = None diff --git a/backend/src/api/routes/search.py b/backend/src/api/routes/search.py index 149e50a..bdc9d15 100644 --- a/backend/src/api/routes/search.py +++ b/backend/src/api/routes/search.py @@ -547,13 +547,21 @@ async def search_document_content( search_results = [] for doc in documents: - # HTML 파일에서 텍스트 검색 + text_content = "" + file_type = "" + + # HTML 파일에서 텍스트 검색 (PDF OCR 결과 또는 서적 HTML) if doc.html_path: try: import os from bs4 import BeautifulSoup - html_file_path = os.path.join("/app/data/documents", doc.html_path) + # 절대 경로 처리 + if doc.html_path.startswith('/'): + html_file_path = doc.html_path + else: + html_file_path = os.path.join("/app/data/documents", doc.html_path) + if os.path.exists(html_file_path): with open(html_file_path, 'r', encoding='utf-8') as f: html_content = f.read() @@ -562,27 +570,75 @@ async def search_document_content( soup = BeautifulSoup(html_content, 'html.parser') text_content = soup.get_text() - # 검색어가 포함된 경우 - if query.lower() in text_content.lower(): - # 검색어 주변 컨텍스트 추출 - context = extract_search_context(text_content, query) + # PDF인지 서적인지 구분 + if doc.pdf_path: + file_type = "PDF" + else: + file_type = "HTML" - # 관련성 점수 계산 - score = 2.0 # 본문 매치는 높은 점수 - - search_results.append(SearchResult( - type="document_content", - id=str(doc.id), - title=f"📄 {doc.title} (본문)", - content=context, - document_id=str(doc.id), - document_title=doc.title, - created_at=doc.created_at, - relevance_score=score - )) except Exception as e: - print(f"문서 본문 검색 오류: {e}") + print(f"HTML 파일 읽기 오류 ({doc.html_path}): {e}") continue + + # PDF 파일 직접 텍스트 추출 (HTML이 없는 경우) + elif doc.pdf_path: + try: + import os + import PyPDF2 + + # 절대 경로 처리 + if doc.pdf_path.startswith('/'): + pdf_file_path = doc.pdf_path + else: + pdf_file_path = os.path.join("/app/data/documents", doc.pdf_path) + + if os.path.exists(pdf_file_path): + with open(pdf_file_path, 'rb') as f: + pdf_reader = PyPDF2.PdfReader(f) + text_pages = [] + + # 모든 페이지에서 텍스트 추출 + for page_num in range(len(pdf_reader.pages)): + page = pdf_reader.pages[page_num] + page_text = page.extract_text() + if page_text.strip(): + text_pages.append(f"[페이지 {page_num + 1}]\n{page_text}") + + text_content = "\n\n".join(text_pages) + file_type = "PDF (직접추출)" + + except Exception as e: + print(f"PDF 파일 읽기 오류 ({doc.pdf_path}): {e}") + continue + + # 검색어가 포함된 경우 + if text_content and query.lower() in text_content.lower(): + # 검색어 주변 컨텍스트 추출 + context = extract_search_context(text_content, query, context_length=300) + + # 관련성 점수 계산 + score = 2.0 # 본문 매치는 높은 점수 + + # 검색어 매치 횟수로 점수 조정 + match_count = text_content.lower().count(query.lower()) + score += min(match_count * 0.1, 1.0) # 최대 1점 추가 + + search_results.append(SearchResult( + type="document_content", + id=str(doc.id), + title=f"📄 {doc.title} ({file_type} 본문)", + content=context, + document_id=str(doc.id), + document_title=doc.title, + created_at=doc.created_at, + relevance_score=score, + highlight_info={ + "file_type": file_type, + "match_count": match_count, + "has_pdf": bool(doc.pdf_path), + "has_html": bool(doc.html_path) + } + )) return search_results diff --git a/frontend/search.html b/frontend/search.html index 802aae6..d5a178c 100644 --- a/frontend/search.html +++ b/frontend/search.html @@ -281,6 +281,12 @@ 🖍️ 하이라이트 개 + + 💬 메모 개 + + + 📖 본문 개 +
PDF를 로드할 수 없습니다
+ +