feat: 완전한 웹 UI 구현 및 문서 처리 파이프라인 완성

 새로운 기능:
- FastAPI 기반 완전한 웹 UI 구현
- 반응형 디자인 (모바일/태블릿 지원)
- 드래그앤드롭 파일 업로드 인터페이스
- 실시간 AI 챗봇 인터페이스
- 문서 관리 및 검색 시스템
- 진행률 표시 및 상태 알림

🎨 UI 구성:
- 메인 대시보드: 서버 상태, 통계, 빠른 접근
- 파일 업로드: 드래그앤드롭, 처리 옵션, 진행률
- 문서 관리: 검색, 정렬, 미리보기, 다운로드
- AI 챗봇: 실시간 대화, 문서 기반 RAG, 빠른 질문

🔧 기술 스택:
- FastAPI + Jinja2 템플릿
- 모던 CSS (그라디언트, 애니메이션)
- Font Awesome 아이콘
- JavaScript (ES6+)

🚀 완성된 기능:
- 파일 업로드 → 텍스트 추출 → 임베딩 → 번역 → HTML 생성
- 벡터 검색 및 RAG 기반 질의응답
- 다중 모델 지원 (기본/부스팅/영어 전용)
- API 키 인증 및 CORS 설정
- NAS 연동 및 파일 내보내기
This commit is contained in:
hyungi
2025-08-14 08:09:48 +09:00
parent ef64aaec84
commit cb009f7393
13 changed files with 2781 additions and 4 deletions

View File

@@ -0,0 +1,493 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8"/>
<title>문서 Industrial Safety and Health Management(7:ED)_0 Contents-2025-08-13 (원문)</title>
<style>
body{font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Noto Sans KR', 'Apple SD Gothic Neo', Arial, sans-serif; line-height:1.6; margin:24px;}
article{max-width: 900px; margin: auto;}
h1{font-size: 1.6rem; margin-bottom: 1rem;}
.chunk{white-space: pre-wrap; margin: 1rem 0;}
</style>
</head>
<body>
<article>
<h1>문서 Industrial Safety and Health Management(7:ED)_0 Contents-2025-08-13 (원문)</h1>
<div class="chunk" id="c0">Industrial Safety and Health
Management
Seventh Edition
C. Ray Asfahl
David W. Rieske
University of Arkansas
Pearson
33o Hudson Street, NY NY 10013</div>
<div class="chunk" id="c1">
Senior Vice President Courseware Portfolio Management: Marcia Horton
Director, Portfolio Management: Engineering, Computer Science & Global Editions: Julian Partridge
Specialist, Higher Ed Portfolio Management: Holly Stark
Portfolio Management Assistant: Emily Egan
Managing Content Producer: Scott Disanno
Content Producer: Carole Snyder
Web Developer: Steve Wright
Rights and Permissions Manager: Ben Ferrini
Manufacturing Buyer, Higher Ed, Lake Side Communications Inc (LSC): Maura Zaldivar-Garcia
Inventory Manager: Ann Lam
Product Marketing Manager: Yvonne Vannatta
Field Marketing Manager: Demetrius Hall
Marketing Assistant: Jon Bryant
Cover Designer: Black Horse Designs
Full-Service Project Manager: Billu Suresh, SPi Global
Composition: SPi Global
Copyright © 2019 Pearson Education, Inc. All rights reserved. Manufactured in the United States of America. This
publication is protected by copyright, and permission should be obtained from the publisher prior to any prohibited
reproduction, storage in a retrieval system, or transmissio</div>
<div class="chunk" id="c2">tates of America. This
publication is protected by copyright, and permission should be obtained from the publisher prior to any prohibited
reproduction, storage in a retrieval system, or transmission in any form or by any means, electronic, mechanical,
photocopying, recording, or likewise. For information regarding permissions, request forms and the appropriate
contacts within the Pearson Education Global Rights & Permissions department, please visit http://www.pearsoned
.compermissions.
Many of the designations by manufacturers and sellers to distinguish their products are claimed as trademarks. Where
those designations appear in this book, and the publisher was aware of a trademark claim, the designations have been
printed in initial caps or all caps.
The author and publisher of this book have used their best efforts in preparing this book. These efforts include the
development, research, and testing of theories and programs to determine their effectiveness. The author and publisher
make no warranty of any kind, expressed or implied, with regard to these programs or the documentation contained
in this book. The author and publisher shall not be liable in any event for</div>
<div class="chunk" id="c3">ublisher
make no warranty of any kind, expressed or implied, with regard to these programs or the documentation contained
in this book. The author and publisher shall not be liable in any event for incidental or consequential damages with, or
arising out of, the furnishing, performance, or use of these programs.
Library of Congress Cataioging in-Publication Data
Names: Asfahl, C. Ray, 1938- author. Rieske, David W., author.
Title: Industrial safety and health management C. Ray Asfahl, David W.
Rieske, University of Arkansas.
Description: Seventh edition. NY, NY : Pearson, [2019]
Includes bibliographical references and index.
Identifiers: LCCN 2017050947 ISBN 9780134630564 (alk. paper)
ISBN 0134630564 (alk. paper)
Subjects: LCSH: Industrial safety. Industrial hygiene.
Classification: LCC T55 .A83 2019 DDC 658.408--dc23 LC record available at https://lccn.loc.gov/2017050947
Pearson ISBN-13: 978-0-13-463056-4
ISBN-10: 0-13-463056-4
17 2024</div>
<div class="chunk" id="c4">
Contents
Preface ix
CHAPTER 1 The Safety and Health Manager 1
A Reasonable Objective 2
Safety versus Health 4
Role in the Corporate Structure 5
Resources at Hand 6
Summary 12
Exercises and Study Questions 12
Research Exercises</div>
<div class="chunk" id="c5">1 The Safety and Health Manager 1
A Reasonable Objective 2
Safety versus Health 4
Role in the Corporate Structure 5
Resources at Hand 6
Summary 12
Exercises and Study Questions 12
Research Exercises 13
CHAPTER 2 Development of the Safety and Health Function 15
Workers Compensation 16
Recordkeeping 21
Accident Cause Analysis 35
Organization of Committees 36
Safety and Health Economics 37
Training 41
Job Placement Testing 43
The Smoke-Free Workplace 44
Bloodborne Pathogens 45
Workplace Violence 47
Summary 48
Exercises and Study Questions 49
Research Exercises 53
CHAPTER Z Concepts of Hazard Avoidance 54
The Enforcement Approach 55
The Psychological Approach 57
The Engineering Approach 59
The Analytical Approach 67
Hazard-Classification Scale 76
Summary 82
Exercises and Study Questions 83
Research Exercises 86
Standards Research Questions 87
iii</div>
<div class="chunk" id="c6">
iv Contents
CHAPTER 4 Impact of Federal Regulation 88
Standards 88
NIOSH 93
Enforcement 94
Public Uproar IOO
Role of the States 102
Political Trends 104
Immigrant Workers 111
Summary 111
Exercises and Study Questions 112
Research Exercises 113
Standards Research Questions 114
CHAPTER 5 Information Systems 115
Hazard Communication 116
Inter</div>
<div class="chunk" id="c7">Trends 104
Immigrant Workers 111
Summary 111
Exercises and Study Questions 112
Research Exercises 113
Standards Research Questions 114
CHAPTER 5 Information Systems 115
Hazard Communication 116
International Standards 123
Environmental Protection Agency 123
Department of Homeland Security 128
Computer Information Systems 129
Summary 131
Exercises and Study Questions 131
Research Exercises 132
Standards Research Questions 133
CHAPTER 6 Process Safety and Disaster Preparedness 134
Process Information 135
Process Analysis 139
Operating Procedures 140
Training 141
Contractor Personnel 142
Acts of Terrorism 142
Workplace Security 145
Active Shooter Incidents 146
Summary 146
Exercises and Study Questions 147
Research Exercises 148
Standards Research Questions 148
CHAPTER 7 Buildings and Facilities 150
Walking and Working Surfaces 151
Exits 162
Illumination 164
Miscellaneous Facilities 165
Sanitation 169
Summary 169</div>
<div class="chunk" id="c8">
Contents v
Exercises and Study Questions 170
Research Exercises 171
Standards Research Questions 171
CHAPTER 8 Ergonomics 172
Facets of Ergonomics 172
Workplace Musculoskeletal Disorders 176
Affected Industries 179
Ergonomics Standards 179
WMSD Management Programs 182
Ergon</div>
<div class="chunk" id="c9">rds Research Questions 171
CHAPTER 8 Ergonomics 172
Facets of Ergonomics 172
Workplace Musculoskeletal Disorders 176
Affected Industries 179
Ergonomics Standards 179
WMSD Management Programs 182
Ergonomic Risk Analysis 184
NIOSH Lifting Equation 185
Sources of Ergonomic Hazards 193
Summary 202
Exercises and Study Questions 203
Research Exercises 204
Standards Research Question 205
CHAPTER 9 Health and Toxic Substances 206
Baseline Examinations 206
Toxic Substances 207
Measures of Exposure 216
Standards Completion Project 220
Detecting Contaminants 222
Summary 229
Exercises and Study Questions 230
Research Exercises 234
Standards Research Questions 235
CHAPTER 10 Environmental Control and Noise 236
Ventilation 236
ASHRAE Standards and Indoor Air Quality 242
Industrial Noise 243
Radiation 260
Summary 260
Exercises and Study Questions 261
Research Exercises 265
Standards Research Questions 265
CHAPTER 11 Flammable and Explosive Materials 267
Flammable Liquids 267
Sources of Ignition 272
Standards Compliance 274
Combustible Liquids 276
Spray Finishing 278</div>
<div class="chunk" id="c10">
vi Contents
Dip Tanks 281
Explosives 281
Liquefied Petroleum Gas 282
Combustible Dust 284
Conclusion 285
Exercises and Study Quest</div>
<div class="chunk" id="c11">tandards Compliance 274
Combustible Liquids 276
Spray Finishing 278</div>
<div class="chunk" id="c12">
vi Contents
Dip Tanks 281
Explosives 281
Liquefied Petroleum Gas 282
Combustible Dust 284
Conclusion 285
Exercises and Study Questions 285
Research Exercises 287
Standards Research Questions 288
CHAPTER 12 Personal Protection and First Aid 289
Protection Need Assessment 290
Personal Protective Equipment (PPE) Training 291
Hearing Protection 292
Determining the Noise Reduction Rating 293
Eye and Face Protection 294
Respiratory Protection 296
Confined Space Entry 309
Head Protection 312
Miscellaneous Personal Protective Equipment 313
First Aid 315
Summary 316
Exercises and Study Questions 317
Research Exercises 319
Standards Research Questions 320
CHAPTER 13 Fire Protection 321
Mechanics of Fire 322
Industrial Fires 322
Fire Prevention 323
Dust Explosions 323
Emergency Evacuation 324
Fire Brigades 326
Fire Extinguishers 327
Standpipe and Hose Systems 329
Automatic Sprinkler Systems 330
Fixed Extinguishing Systems 330
Summary 331
Exercises and Study Questions 332
Research Exercises 334
Standards Research Questions 334
CHAPTER 14 Materials Handling and Storage 335
Materials Storage 336
Industrial Trucks 337
Passenger</div>
<div class="chunk" id="c13">ummary 331
Exercises and Study Questions 332
Research Exercises 334
Standards Research Questions 334
CHAPTER 14 Materials Handling and Storage 335
Materials Storage 336
Industrial Trucks 337
Passengers 343
Cranes 344</div>
<div class="chunk" id="c14">
Contents vii
Slings 358
Conveyors 362
Lifting 363
Summary 365
Exercises and Study Questions 365
Research Exercise 368
CHAPTER 15 Machine Guarding 369
General Machine Guarding 369
Safeguarding the Point of Operation 379
Power Presses 386
Heat Processes 406
Grinding Machines 406
Saws 408
Miscellaneous Machine Guarding 413
Miscellaneous Machines and Processes 416
Industrial Robots 417
Evolution in Robotics and Intelligent Machines 420
Summary 421
Exercises and Study Questions 422
Standards Research Questions 425
CHAPTER 16 Welding 426
Process Terminology 426
Gas Welding Hazards 430
Arc Welding Hazards 437
Resistance Welding Hazards 438
Fires and Explosions 439
Eye Protection 441
Protective Clothing 442
Gases and Fumes 443
Summary 446
Exercises and Study Questions 447
Research Exercises 449
Standards Research Questions 450
CHAPTER 17 Electrical Hazards 451
Electrocution Hazards 451
Fire Hazards 464
Arc Flash 469
Test Equipment 471
Exposure to High-Voltage Power Lines 473</div>
<div class="chunk" id="c15">ch Exercises 449
Standards Research Questions 450
CHAPTER 17 Electrical Hazards 451
Electrocution Hazards 451
Fire Hazards 464
Arc Flash 469
Test Equipment 471
Exposure to High-Voltage Power Lines 473
Frequent Violations 473
Summary 474
Exercises and Study Questions 475</div>
<div class="chunk" id="c16">
viii Contents
Research Exercises 478
Standards Research Questions 478
CHAPTER 18 Construction 479
General Facilities 480
Personal Protective Equipment 482
Fire Protection 486
Tools 486
Electrical 488
Ladders and Scaffolds 490
Floors and Stairways 493
Cranes and Hoists 493
Heavy Vehicles and Equipment 498
ROPS 498
Trenching and Excavations 501
Concrete Work 505
Steel Erection 507
Demolition 508
Explosive Blasting 509
Electric Utilities 510
Summary 511
Exercises and Study Questions 512
Research Exercises 515
APPENDICES
A OSHA Permissible Exposure Limits 516
B Medical Treatment 535
C First-Aid Treatment 536
D Classification of Medical Treatment 538
E Highly Hazardous Chemicals, Toxics, and Reactives 540
F North American Industry Classification System (NAICS) Code 544
G States Having Federally Approved State Plans for
Occupational Safety and Health Standards and Enforcement 548
Bibliography 549
Glossary 560
Index 568</div>
<div class="chunk" id="c17">Industry Classification System (NAICS) Code 544
G States Having Federally Approved State Plans for
Occupational Safety and Health Standards and Enforcement 548
Bibliography 549
Glossary 560
Index 568</div>
<div class="chunk" id="c18">
Preface
The seventh edition of Industrial Safety and Health Management remains true to
the purpose of engaging the reader in the common sense approaches to safety and
health from a concept, process, and compliance perspective. The book retains its
easy-to-read format while increasing the retention of the reader through additional
case studies and statistics, relevant topics, and additional explanation of difficult-to-
Understand concepts.
Much of the safety change we see comes on the heels of major disasters or
social trends and changes. The past decade has seen many. The explosion of a major
sugar processing plant has driven a renewed focus on combustible dust, an outbreak
of Ebola brought focus on contagious diseases, the sinking of a major oil derrick
initiated a discussion on regulatory oversight and process health, and numerous
acts of violence bring our attention to security in the workplace. Social trends such
as the rise of "gig" or "on-demand" employment have brou</div>
<div class="chunk" id="c19">n regulatory oversight and process health, and numerous
acts of violence bring our attention to security in the workplace. Social trends such
as the rise of "gig" or "on-demand" employment have brought about questions
of the definition of an "employee" and coverage for safety nets such as workers
compensation. Regulatory changes have even precipitated the complete removal of
workers compensation in some states. In other areas, the effectiveness of workers
compensation led to a robust dialog on whether or not a permanently injured em­
ployee truly receives compensation commensurate to his or her injury. Meanwhile
rises in the number of states legalizing marijuana have caused companies to ques­
tion current drug screening programs and medical treatment programs.
Regulation has changed as well. The adoption of the Globally Harmonized System
for Hazard Communication or GHS has completely changed the way we think about
hazard communication. The new system crosses language barriers and helps workers
who may not be able to read or may not be fluent in a given language with a series of
pictograms depicting the dangers of certain chemicals. Hazards are now categorized
in a st</div>
<div class="chunk" id="c20">rs and helps workers
who may not be able to read or may not be fluent in a given language with a series of
pictograms depicting the dangers of certain chemicals. Hazards are now categorized
in a standard way which drives increased consistency of approach. For the first time in
nearly 20 years, fines associated with citations have gone up considerably. Meanwhile,
record fines have been levied against corporations associated with major disasters. The
classification of companies has also been changed to the modernized North America
Industry Classification System (NAICS).
As the authors have used the text in their classrooms, a critical focus has been
on addressing the most common areas that students will be expected to apply in
an industrial setting. Additional explanation around the concepts of PELs has been
given to help students to understand the differences among PELS, Ceilings, and
other measures. Calculations around the Noise Reduction Rating (NRR) and how it
is practically used will help students address the prevalent danger of industrial noise
in their work environments. In addition, explained in more detail is sometimes the
confusing concept of applying workers</div>
<div class="chunk" id="c21">ally used will help students address the prevalent danger of industrial noise
in their work environments. In addition, explained in more detail is sometimes the
confusing concept of applying workers compensation and practical aspects of pro­
tecting employees.</div>
<div class="chunk" id="c22">
X Preface
WHAT'S NEW IN THIS EDITION?
For easy reference, the authors have summarized the new features of this edition as
follows:
• Overhaul of hazard communication standard and incorporation of the Globally
Harmonized System
• Increased discussion on workers compensation rates and calculations
• Trends in workers compensation privatization and states “opting-out”
• Layers of coverage for permanent injuries
• Coverage of the trends in the gig economy and the changing nature of
employees
• OSHA usage of reporting in “Big Data”
• Changes in SIC to North American Industry Classification System (NAICS)
• Discussion of bloodborne pathogens and protecting workers from diseases such
as HIV and Ebola
• Increased coverage of workplace security
• Discussion of preparation and response techniques for active shooter scenarios
• Impact of medical marijuana
• Changes in OSHA citation penalty levels
• Increased coverage of Targe</div>
<div class="chunk" id="c23">orkplace security
• Discussion of preparation and response techniques for active shooter scenarios
• Impact of medical marijuana
• Changes in OSHA citation penalty levels
• Increased coverage of Target Industry programs
• Coverage of fatigue and worker safety
• Practical discussion of PELs, STELs, Ceiling Limits and how they interact
• Changes to flammable liquid classification
• Coverage of calculations and usage of Noise Reduction Rating (NRR)
• Coverage of long-term health impact to World Trade Center first responders
• OSHA s work against the dangers of combustible dust
• Additional practical and pragmatic assessment of penalty levels
• Additional review of OSHA programs such as SHARP and VPP as OSHA is
increasing its collaborative approach in recent years
• Additional case studies to bring home to readers about the concepts of safety and
health</div>
<div class="chunk" id="c24">
Preface xi
ACKNOWLEDGMENTS
Both authors wish to express their appreciation to companies and individuals who have
contributed ideas and support for the seventh edition. Special thanks to Richard
Wallace, Jimmy Baker, and the entire team at Pratt & Whitney for ideas, pictures,
and best practices from their world-class facility. And</div>
<div class="chunk" id="c25">d support for the seventh edition. Special thanks to Richard
Wallace, Jimmy Baker, and the entire team at Pratt & Whitney for ideas, pictures,
and best practices from their world-class facility. Andrew Hilliard, President of Safety
Maker, Inc. and E.C. Daven, President of Safety Services, Inc. provided valuable insights
and visual examples. Erica Asfahl provided mechanical engineering advice. David Trigg
and David Bryan answered questions and provided data on OSHA developments.
We are grateful to Ken Kolosh and the team at the National Safety Council for their
statistics provided in many areas of the text. Tara Mercer and the National Council on
Compensation Insurance shared valuable insights into trends and developments such
as the gig economy and the impact of medical marijuana. We learned from Alejandra
Nolibos about developments in state workers compensation changes. Finally, we
dedicate this edition to our patient and supportive families who have endured the
process of bringing forth this seventh edition.
C. Ray Asfahl
David W. Rieske</div>
</article>
</body>
</html>

20
outputs/html/test.html Normal file
View File

@@ -0,0 +1,20 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8"/>
<title>문서 test-doc-001 (원문)</title>
<style>
body{font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Noto Sans KR', 'Apple SD Gothic Neo', Arial, sans-serif; line-height:1.6; margin:24px;}
article{max-width: 900px; margin: auto;}
h1{font-size: 1.6rem; margin-bottom: 1rem;}
.chunk{white-space: pre-wrap; margin: 1rem 0;}
</style>
</head>
<body>
<article>
<h1>문서 test-doc-001 (원문)</h1>
<div class="chunk" id="c0">테스트 문서입니다.</div>
</article>
</body>
</html>

20
outputs/html/ui-test.html Normal file
View File

@@ -0,0 +1,20 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8"/>
<title>문서 ui-test-001 (원문)</title>
<style>
body{font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Noto Sans KR', 'Apple SD Gothic Neo', Arial, sans-serif; line-height:1.6; margin:24px;}
article{max-width: 900px; margin: auto;}
h1{font-size: 1.6rem; margin-bottom: 1rem;}
.chunk{white-space: pre-wrap; margin: 1rem 0;}
</style>
</head>
<body>
<article>
<h1>문서 ui-test-001 (원문)</h1>
<div class="chunk" id="c0">UI 테스트용 문서입니다. 웹 인터페이스를 통한 업로드 테스트.</div>
</article>
</body>
</html>

View File

@@ -4,3 +4,5 @@ requests==2.32.4
pydantic==2.8.2 pydantic==2.8.2
pypdf==6.0.0 pypdf==6.0.0
tiktoken==0.11.0 tiktoken==0.11.0
python-multipart
jinja2

View File

@@ -1,11 +1,16 @@
from __future__ import annotations from __future__ import annotations
from fastapi import FastAPI, HTTPException, Depends, UploadFile, File, Form from fastapi import FastAPI, HTTPException, Depends, UploadFile, File, Form, Request
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.templating import Jinja2Templates
from fastapi.staticfiles import StaticFiles
from fastapi.responses import HTMLResponse
from pydantic import BaseModel from pydantic import BaseModel
from typing import List, Dict, Any from typing import List, Dict, Any
import shutil import shutil
from pathlib import Path from pathlib import Path
import os
from datetime import datetime
from .config import settings from .config import settings
from .ollama_client import OllamaClient from .ollama_client import OllamaClient
@@ -18,6 +23,14 @@ from .pipeline import DocumentPipeline
app = FastAPI(title="Local AI Server", version="0.2.1") app = FastAPI(title="Local AI Server", version="0.2.1")
# 템플릿과 정적 파일 설정
templates = Jinja2Templates(directory="templates")
app.mount("/static", StaticFiles(directory="static"), name="static")
# HTML 출력 디렉토리도 정적 파일로 서빙
if Path("outputs/html").exists():
app.mount("/html", StaticFiles(directory="outputs/html"), name="html")
# CORS # CORS
import os import os
cors_origins = os.getenv("CORS_ORIGINS", "*") cors_origins = os.getenv("CORS_ORIGINS", "*")
@@ -68,6 +81,7 @@ class PipelineIngestRequest(BaseModel):
summarize: bool = False summarize: bool = False
summary_sentences: int = 5 summary_sentences: int = 5
summary_language: str | None = None summary_language: str | None = None
html_basename: str | None = None
@app.get("/health") @app.get("/health")
@@ -179,6 +193,7 @@ def pipeline_ingest(req: PipelineIngestRequest, _: None = Depends(require_api_ke
summarize=req.summarize, summarize=req.summarize,
summary_sentences=req.summary_sentences, summary_sentences=req.summary_sentences,
summary_language=req.summary_language, summary_language=req.summary_language,
html_basename=req.html_basename,
) )
exported_html: str | None = None exported_html: str | None = None
if result.html_path and settings.export_html_dir: if result.html_path and settings.export_html_dir:
@@ -233,6 +248,7 @@ async def pipeline_ingest_file(
generate_html=generate_html, generate_html=generate_html,
translate=translate, translate=translate,
target_language=target_language, target_language=target_language,
html_basename=file.filename,
) )
exported_html: str | None = None exported_html: str | None = None
if result.html_path and settings.export_html_dir: if result.html_path and settings.export_html_dir:
@@ -356,3 +372,102 @@ def chat_completions(req: ChatCompletionsRequest, _: None = Depends(require_api_
], ],
} }
# =============================================================================
# UI 라우트들
# =============================================================================
@app.get("/", response_class=HTMLResponse)
async def dashboard(request: Request):
"""메인 대시보드 페이지"""
# 서버 상태 가져오기
status = {
"base_model": settings.base_model,
"boost_model": settings.boost_model,
"embedding_model": settings.embedding_model,
"index_loaded": len(index.rows) if index else 0,
}
# 최근 문서 (임시 데이터 - 실제로는 DB나 파일에서 가져올 것)
recent_documents = []
# 통계 (임시 데이터)
stats = {
"total_documents": len(index.rows) if index else 0,
"total_chunks": len(index.rows) if index else 0,
"today_processed": 0,
}
return templates.TemplateResponse("index.html", {
"request": request,
"status": status,
"recent_documents": recent_documents,
"stats": stats,
})
@app.get("/upload", response_class=HTMLResponse)
async def upload_page(request: Request):
"""파일 업로드 페이지"""
return templates.TemplateResponse("upload.html", {
"request": request,
"api_key": os.getenv("API_KEY", "")
})
def format_file_size(bytes_size):
"""파일 크기 포맷팅 헬퍼 함수"""
if bytes_size == 0:
return "0 Bytes"
k = 1024
sizes = ["Bytes", "KB", "MB", "GB"]
i = int(bytes_size / k)
if i >= len(sizes):
i = len(sizes) - 1
return f"{bytes_size / (k ** i):.2f} {sizes[i]}"
@app.get("/documents", response_class=HTMLResponse)
async def documents_page(request: Request):
"""문서 관리 페이지"""
# HTML 파일 목록 가져오기
html_dir = Path("outputs/html")
html_files = []
if html_dir.exists():
for file in html_dir.glob("*.html"):
stat = file.stat()
html_files.append({
"name": file.name,
"size": stat.st_size,
"created": datetime.fromtimestamp(stat.st_ctime).strftime("%Y-%m-%d %H:%M"),
"url": f"/html/{file.name}"
})
# 날짜순 정렬 (최신순)
html_files.sort(key=lambda x: x["created"], reverse=True)
return templates.TemplateResponse("documents.html", {
"request": request,
"documents": html_files,
"formatFileSize": format_file_size,
})
@app.get("/chat", response_class=HTMLResponse)
async def chat_page(request: Request):
"""AI 챗봇 페이지"""
# 서버 상태 정보
status = {
"base_model": settings.base_model,
"boost_model": settings.boost_model,
"embedding_model": settings.embedding_model,
"index_loaded": len(index.rows) if index else 0,
}
return templates.TemplateResponse("chat.html", {
"request": request,
"status": status,
"current_time": datetime.now().strftime("%H:%M"),
"api_key": os.getenv("API_KEY", "")
})

View File

@@ -73,8 +73,10 @@ class DocumentPipeline:
translated.append(content.strip()) translated.append(content.strip())
return translated return translated
def build_html(self, doc_id: str, title: str, ko_text: str) -> str: def build_html(self, basename: str, title: str, ko_text: str) -> str:
html_path = self.output_dir / "html" / f"{doc_id}.html" # Ensure .html suffix and sanitize basename
safe_base = Path(basename).stem + ".html"
html_path = self.output_dir / "html" / safe_base
html = f""" html = f"""
<!doctype html> <!doctype html>
<html lang=\"ko\">\n<head>\n<meta charset=\"utf-8\"/>\n<title>{title}</title>\n<style> <html lang=\"ko\">\n<head>\n<meta charset=\"utf-8\"/>\n<title>{title}</title>\n<style>
@@ -102,6 +104,7 @@ h1{{font-size: 1.6rem; margin-bottom: 1rem;}}
summarize: bool = False, summarize: bool = False,
summary_sentences: int = 5, summary_sentences: int = 5,
summary_language: str | None = None, summary_language: str | None = None,
html_basename: str | None = None,
) -> PipelineResult: ) -> PipelineResult:
parts = chunk_text(text, max_chars=1200, overlap=200) parts = chunk_text(text, max_chars=1200, overlap=200)
@@ -124,7 +127,8 @@ h1{{font-size: 1.6rem; margin-bottom: 1rem;}}
html_path: str | None = None html_path: str | None = None
if generate_html: if generate_html:
title_suffix = "요약+번역본" if (summarize and translate) else ("요약본" if summarize else ("번역본" if translate else "원문")) title_suffix = "요약+번역본" if (summarize and translate) else ("요약본" if summarize else ("번역본" if translate else "원문"))
html_path = self.build_html(doc_id, title=f"문서 {doc_id} ({title_suffix})", ko_text="\n\n".join(translated)) basename = html_basename or f"{doc_id}.html"
html_path = self.build_html(basename, title=f"문서 {doc_id} ({title_suffix})", ko_text="\n\n".join(translated))
return PipelineResult(doc_id=doc_id, html_path=html_path, added_chunks=added, chunks=len(translated)) return PipelineResult(doc_id=doc_id, html_path=html_path, added_chunks=added, chunks=len(translated))

394
static/css/style.css Normal file
View File

@@ -0,0 +1,394 @@
/* 기본 스타일 리셋 */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Noto Sans KR', 'Apple SD Gothic Neo', Arial, sans-serif;
line-height: 1.6;
color: #333;
background-color: #f8f9fa;
min-height: 100vh;
display: flex;
flex-direction: column;
}
/* 네비게이션 */
.navbar {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 1rem 0;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.nav-container {
max-width: 1200px;
margin: 0 auto;
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 2rem;
}
.nav-logo {
color: white;
text-decoration: none;
font-size: 1.5rem;
font-weight: bold;
}
.nav-logo i {
margin-right: 0.5rem;
}
.nav-menu {
display: flex;
list-style: none;
gap: 2rem;
}
.nav-link {
color: white;
text-decoration: none;
padding: 0.5rem 1rem;
border-radius: 5px;
transition: background-color 0.3s;
}
.nav-link:hover {
background-color: rgba(255,255,255,0.2);
}
/* 메인 콘텐츠 */
.main-content {
flex: 1;
max-width: 1200px;
margin: 2rem auto;
padding: 0 2rem;
width: 100%;
}
/* 카드 스타일 */
.card {
background: white;
border-radius: 10px;
padding: 2rem;
margin-bottom: 2rem;
box-shadow: 0 4px 15px rgba(0,0,0,0.1);
border: 1px solid #e9ecef;
}
.card-header {
margin-bottom: 1.5rem;
padding-bottom: 1rem;
border-bottom: 2px solid #f8f9fa;
}
.card-title {
font-size: 1.5rem;
color: #495057;
margin-bottom: 0.5rem;
}
.card-subtitle {
color: #6c757d;
font-size: 0.9rem;
}
/* 그리드 레이아웃 */
.grid {
display: grid;
gap: 2rem;
}
.grid-2 {
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
}
.grid-3 {
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
}
/* 버튼 */
.btn {
display: inline-block;
padding: 0.75rem 1.5rem;
border: none;
border-radius: 5px;
text-decoration: none;
text-align: center;
cursor: pointer;
transition: all 0.3s;
font-size: 1rem;
font-weight: 500;
}
.btn-primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4);
}
.btn-success {
background: linear-gradient(135deg, #56ab2f 0%, #a8e6cf 100%);
color: white;
}
.btn-success:hover {
transform: translateY(-2px);
box-shadow: 0 4px 15px rgba(86, 171, 47, 0.4);
}
.btn-danger {
background: linear-gradient(135deg, #ff6b6b 0%, #ee5a52 100%);
color: white;
}
.btn-secondary {
background: #6c757d;
color: white;
}
/* 폼 요소 */
.form-group {
margin-bottom: 1.5rem;
}
.form-label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
color: #495057;
}
.form-control {
width: 100%;
padding: 0.75rem;
border: 1px solid #ced4da;
border-radius: 5px;
font-size: 1rem;
transition: border-color 0.3s, box-shadow 0.3s;
}
.form-control:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
/* 드래그 앤 드롭 영역 */
.drop-zone {
border: 2px dashed #ced4da;
border-radius: 10px;
padding: 3rem;
text-align: center;
transition: all 0.3s;
background-color: #f8f9fa;
cursor: pointer;
}
.drop-zone:hover,
.drop-zone.dragover {
border-color: #667eea;
background-color: rgba(102, 126, 234, 0.05);
}
.drop-zone i {
font-size: 3rem;
color: #6c757d;
margin-bottom: 1rem;
}
.drop-zone.dragover i {
color: #667eea;
}
/* 상태 표시 */
.status {
padding: 0.5rem 1rem;
border-radius: 20px;
font-size: 0.8rem;
font-weight: 500;
display: inline-block;
}
.status-success {
background-color: #d4edda;
color: #155724;
}
.status-processing {
background-color: #fff3cd;
color: #856404;
}
.status-error {
background-color: #f8d7da;
color: #721c24;
}
/* 테이블 */
.table {
width: 100%;
border-collapse: collapse;
margin-top: 1rem;
}
.table th,
.table td {
padding: 1rem;
text-align: left;
border-bottom: 1px solid #e9ecef;
}
.table th {
background-color: #f8f9fa;
font-weight: 600;
color: #495057;
}
.table tbody tr:hover {
background-color: #f8f9fa;
}
/* 진행률 바 */
.progress {
width: 100%;
height: 8px;
background-color: #e9ecef;
border-radius: 4px;
overflow: hidden;
margin: 1rem 0;
}
.progress-bar {
height: 100%;
background: linear-gradient(90deg, #667eea, #764ba2);
transition: width 0.3s;
border-radius: 4px;
}
/* 알림 */
.alert {
padding: 1rem;
border-radius: 5px;
margin-bottom: 1rem;
border-left: 4px solid;
}
.alert-success {
background-color: #d4edda;
color: #155724;
border-left-color: #28a745;
}
.alert-danger {
background-color: #f8d7da;
color: #721c24;
border-left-color: #dc3545;
}
.alert-info {
background-color: #d1ecf1;
color: #0c5460;
border-left-color: #17a2b8;
}
/* 푸터 */
.footer {
background-color: #343a40;
color: white;
text-align: center;
padding: 1rem 0;
margin-top: auto;
}
/* 반응형 디자인 */
@media (max-width: 768px) {
.nav-container {
flex-direction: column;
gap: 1rem;
}
.nav-menu {
flex-wrap: wrap;
justify-content: center;
gap: 1rem;
}
.main-content {
padding: 0 1rem;
}
.grid-2,
.grid-3 {
grid-template-columns: 1fr;
}
}
/* 로딩 스피너 */
.spinner {
display: inline-block;
width: 20px;
height: 20px;
border: 3px solid rgba(255,255,255,.3);
border-radius: 50%;
border-top-color: #fff;
animation: spin 1s ease-in-out infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* 채팅 인터페이스 */
.chat-container {
height: 500px;
display: flex;
flex-direction: column;
border: 1px solid #e9ecef;
border-radius: 10px;
overflow: hidden;
}
.chat-messages {
flex: 1;
padding: 1rem;
overflow-y: auto;
background-color: #f8f9fa;
}
.chat-input-area {
padding: 1rem;
background-color: white;
border-top: 1px solid #e9ecef;
display: flex;
gap: 0.5rem;
}
.chat-input {
flex: 1;
}
.message {
margin-bottom: 1rem;
padding: 0.75rem;
border-radius: 10px;
max-width: 70%;
}
.message.user {
background-color: #667eea;
color: white;
margin-left: auto;
}
.message.assistant {
background-color: white;
border: 1px solid #e9ecef;
}

87
static/js/main.js Normal file
View File

@@ -0,0 +1,87 @@
// 공통 JavaScript 기능들
// API 호출 헬퍼
async function apiCall(url, options = {}) {
const defaultOptions = {
headers: {
'Content-Type': 'application/json',
},
};
const response = await fetch(url, { ...defaultOptions, ...options });
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
}
// 파일 크기 포맷팅
function formatFileSize(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
// 날짜 포맷팅
function formatDate(dateString) {
const date = new Date(dateString);
return date.toLocaleString('ko-KR');
}
// 토스트 알림
function showToast(message, type = 'info') {
const toast = document.createElement('div');
toast.className = `alert alert-${type} toast`;
toast.textContent = message;
toast.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
z-index: 1000;
min-width: 300px;
animation: slideIn 0.3s ease;
`;
document.body.appendChild(toast);
setTimeout(() => {
toast.style.animation = 'slideOut 0.3s ease';
setTimeout(() => {
document.body.removeChild(toast);
}, 300);
}, 3000);
}
// 로딩 상태 관리
function setLoading(element, loading = true) {
if (loading) {
element.disabled = true;
element.innerHTML = '<span class="spinner"></span> 처리 중...';
} else {
element.disabled = false;
element.innerHTML = element.getAttribute('data-original-text') || '완료';
}
}
// 애니메이션 CSS 추가
const style = document.createElement('style');
style.textContent = `
@keyframes slideIn {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
@keyframes slideOut {
from { transform: translateX(0); opacity: 1; }
to { transform: translateX(100%); opacity: 0; }
}
.toast {
animation: slideIn 0.3s ease;
}
`;
document.head.appendChild(style);

39
templates/base.html Normal file
View File

@@ -0,0 +1,39 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}AI 문서 처리 서버{% endblock %}</title>
<link href="/static/css/style.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
</head>
<body>
<nav class="navbar">
<div class="nav-container">
<a href="/" class="nav-logo">
<i class="fas fa-robot"></i> AI 문서 서버
</a>
<ul class="nav-menu">
<li><a href="/" class="nav-link">대시보드</a></li>
<li><a href="/upload" class="nav-link">업로드</a></li>
<li><a href="/documents" class="nav-link">문서관리</a></li>
<li><a href="/chat" class="nav-link">AI 챗봇</a></li>
<li><a href="/docs" class="nav-link" target="_blank">API 문서</a></li>
</ul>
</div>
</nav>
<main class="main-content">
{% block content %}{% endblock %}
</main>
<footer class="footer">
<div class="footer-content">
<p>&copy; 2025 AI 문서 처리 서버 | Mac Mini M4 Pro 64GB</p>
</div>
</footer>
<script src="/static/js/main.js"></script>
{% block scripts %}{% endblock %}
</body>
</html>

631
templates/chat.html Normal file
View File

@@ -0,0 +1,631 @@
{% extends "base.html" %}
{% block title %}AI 챗봇 - AI 문서 처리 서버{% endblock %}
{% block content %}
<div class="grid grid-2">
<!-- 채팅 영역 -->
<div class="card chat-card">
<div class="card-header">
<h2 class="card-title">
<i class="fas fa-robot"></i> AI 문서 챗봇
</h2>
<div class="chat-status">
<span class="status status-success">
<i class="fas fa-circle"></i> 온라인
</span>
</div>
</div>
<div class="chat-container">
<div class="chat-messages" id="chatMessages">
<div class="message assistant">
<div class="message-content">
<i class="fas fa-robot message-icon"></i>
<div class="message-text">
안녕하세요! 저는 문서 기반 AI 어시스턴트입니다.
업로드된 문서들에 대해 질문하시면 답변해드리겠습니다.
</div>
</div>
<div class="message-time">{{ current_time }}</div>
</div>
</div>
<div class="chat-input-area">
<div class="input-group">
<input type="text" class="form-control chat-input" id="messageInput"
placeholder="메시지를 입력하세요..." maxlength="1000">
<button type="button" class="btn btn-primary" id="sendBtn" onclick="sendMessage()">
<i class="fas fa-paper-plane"></i>
</button>
</div>
<div class="input-options">
<label class="checkbox-label">
<input type="checkbox" id="useRAG" checked>
<i class="fas fa-search"></i> 문서 검색 사용
</label>
<label class="checkbox-label">
<input type="checkbox" id="forceBoost">
<i class="fas fa-rocket"></i> 고성능 모델 사용
</label>
</div>
</div>
</div>
</div>
<!-- 설정 및 정보 패널 -->
<div class="settings-panel">
<!-- 빠른 질문 -->
<div class="card">
<h3><i class="fas fa-lightning-bolt"></i> 빠른 질문</h3>
<div class="quick-questions">
<button onclick="askQuickQuestion('전체 문서를 요약해주세요')" class="btn btn-sm btn-outline">
문서 요약
</button>
<button onclick="askQuickQuestion('주요 키워드를 추출해주세요')" class="btn btn-sm btn-outline">
키워드 추출
</button>
<button onclick="askQuickQuestion('이 문서의 핵심 내용은 무엇인가요?')" class="btn btn-sm btn-outline">
핵심 내용
</button>
<button onclick="askQuickQuestion('관련된 다른 문서가 있나요?')" class="btn btn-sm btn-outline">
관련 문서
</button>
</div>
</div>
<!-- 검색 결과 -->
<div class="card">
<h3><i class="fas fa-search"></i> 관련 문서 검색</h3>
<input type="text" class="form-control mb-2" id="searchInput"
placeholder="문서 내용 검색..." onkeyup="searchDocuments(event)">
<div class="search-results" id="searchResults">
<p class="text-muted">검색어를 입력하세요</p>
</div>
</div>
<!-- 모델 정보 -->
<div class="card">
<h3><i class="fas fa-cog"></i> 모델 설정</h3>
<div class="model-info">
<div class="info-item">
<strong>기본 모델:</strong> {{ status.base_model }}
</div>
<div class="info-item">
<strong>부스트 모델:</strong> {{ status.boost_model }}
</div>
<div class="info-item">
<strong>임베딩 모델:</strong> {{ status.embedding_model }}
</div>
<div class="info-item">
<strong>인덱스 문서:</strong> {{ status.index_loaded }}개
</div>
</div>
</div>
<!-- 채팅 기록 관리 -->
<div class="card">
<h3><i class="fas fa-history"></i> 대화 관리</h3>
<div class="chat-controls">
<button onclick="clearChat()" class="btn btn-sm btn-secondary w-100 mb-2">
<i class="fas fa-trash"></i> 대화 기록 삭제
</button>
<button onclick="exportChat()" class="btn btn-sm btn-outline w-100">
<i class="fas fa-download"></i> 대화 내용 내보내기
</button>
</div>
</div>
</div>
</div>
<style>
.chat-card {
height: 600px;
display: flex;
flex-direction: column;
}
.chat-card .card-header {
display: flex;
justify-content: space-between;
align-items: center;
flex-shrink: 0;
}
.chat-status {
display: flex;
align-items: center;
gap: 0.5rem;
}
.chat-container {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 1rem;
background-color: #f8f9fa;
border-radius: 8px;
margin-bottom: 1rem;
}
.message {
margin-bottom: 1rem;
display: flex;
flex-direction: column;
}
.message.user {
align-items: flex-end;
}
.message.assistant {
align-items: flex-start;
}
.message-content {
display: flex;
align-items: flex-start;
gap: 0.5rem;
max-width: 80%;
}
.message.user .message-content {
flex-direction: row-reverse;
}
.message-icon {
font-size: 1.2rem;
padding: 0.5rem;
border-radius: 50%;
min-width: 40px;
min-height: 40px;
display: flex;
align-items: center;
justify-content: center;
}
.message.user .message-icon {
background-color: #667eea;
color: white;
}
.message.assistant .message-icon {
background-color: #28a745;
color: white;
}
.message-text {
background: white;
padding: 0.75rem 1rem;
border-radius: 10px;
border: 1px solid #e9ecef;
line-height: 1.5;
}
.message.user .message-text {
background: #667eea;
color: white;
border-color: #667eea;
}
.message-time {
font-size: 0.75rem;
color: #6c757d;
margin-top: 0.25rem;
margin-left: 3rem;
}
.message.user .message-time {
margin-left: 0;
margin-right: 3rem;
text-align: right;
}
.chat-input-area {
flex-shrink: 0;
}
.input-group {
display: flex;
gap: 0.5rem;
}
.input-group .form-control {
flex: 1;
}
.input-options {
display: flex;
gap: 1rem;
margin-top: 0.5rem;
font-size: 0.9rem;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 0.25rem;
cursor: pointer;
color: #6c757d;
}
.settings-panel {
display: flex;
flex-direction: column;
gap: 1rem;
}
.quick-questions {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.btn-outline {
background: transparent;
border: 1px solid #667eea;
color: #667eea;
text-align: left;
padding: 0.5rem;
}
.btn-outline:hover {
background: #667eea;
color: white;
}
.search-results {
max-height: 200px;
overflow-y: auto;
}
.search-result-item {
padding: 0.5rem;
border: 1px solid #e9ecef;
border-radius: 5px;
margin-bottom: 0.5rem;
cursor: pointer;
transition: background-color 0.2s;
}
.search-result-item:hover {
background-color: #f8f9fa;
}
.search-result-title {
font-weight: 500;
margin-bottom: 0.25rem;
}
.search-result-snippet {
font-size: 0.85rem;
color: #6c757d;
line-height: 1.4;
}
.info-item {
margin-bottom: 0.5rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid #f8f9fa;
}
.info-item:last-child {
border-bottom: none;
margin-bottom: 0;
}
.chat-controls .btn {
justify-content: center;
display: flex;
align-items: center;
gap: 0.5rem;
}
.w-100 { width: 100%; }
.mb-2 { margin-bottom: 0.5rem; }
.text-muted { color: #6c757d; }
/* 로딩 애니메이션 */
.typing-indicator {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.75rem 1rem;
background: white;
border-radius: 10px;
border: 1px solid #e9ecef;
}
.typing-dot {
width: 8px;
height: 8px;
background-color: #6c757d;
border-radius: 50%;
animation: typing 1.5s infinite;
}
.typing-dot:nth-child(2) { animation-delay: 0.2s; }
.typing-dot:nth-child(3) { animation-delay: 0.4s; }
@keyframes typing {
0%, 60%, 100% { opacity: 0.3; }
30% { opacity: 1; }
}
</style>
{% endblock %}
{% block scripts %}
<script>
let chatHistory = [];
let isWaitingForResponse = false;
// DOM 요소들
const chatMessages = document.getElementById('chatMessages');
const messageInput = document.getElementById('messageInput');
const sendBtn = document.getElementById('sendBtn');
const searchInput = document.getElementById('searchInput');
const searchResults = document.getElementById('searchResults');
const useRAGCheckbox = document.getElementById('useRAG');
const forceBoostCheckbox = document.getElementById('forceBoost');
// 페이지 로드 시 초기화
document.addEventListener('DOMContentLoaded', function() {
messageInput.addEventListener('keypress', function(e) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
});
// URL 파라미터 확인 (특정 문서로 대화하기)
const urlParams = new URLSearchParams(window.location.search);
const doc = urlParams.get('doc');
if (doc) {
addMessage('assistant', `"${doc}" 문서에 대해 질문해주세요. 어떤 내용이 궁금하신가요?`);
}
});
// 메시지 전송
async function sendMessage() {
const message = messageInput.value.trim();
if (!message || isWaitingForResponse) return;
// 사용자 메시지 추가
addMessage('user', message);
messageInput.value = '';
isWaitingForResponse = true;
sendBtn.disabled = true;
// 타이핑 인디케이터 표시
const typingId = addTypingIndicator();
try {
const response = await fetch('/chat', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Key': '{{ api_key }}'
},
body: JSON.stringify({
messages: chatHistory,
use_rag: useRAGCheckbox.checked,
force_boost: forceBoostCheckbox.checked,
top_k: 5
})
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
// 타이핑 인디케이터 제거
removeTypingIndicator(typingId);
// AI 응답 추가
addMessage('assistant', data.response.message?.content || data.response.response || '응답을 받지 못했습니다.');
} catch (error) {
console.error('Chat error:', error);
removeTypingIndicator(typingId);
addMessage('assistant', '죄송합니다. 응답 중 오류가 발생했습니다. 다시 시도해주세요.');
} finally {
isWaitingForResponse = false;
sendBtn.disabled = false;
}
}
// 메시지 추가
function addMessage(sender, content) {
const messageDiv = document.createElement('div');
messageDiv.className = `message ${sender}`;
const now = new Date().toLocaleTimeString('ko-KR', {
hour: '2-digit',
minute: '2-digit'
});
const icon = sender === 'user' ? 'fas fa-user' : 'fas fa-robot';
messageDiv.innerHTML = `
<div class="message-content">
<i class="${icon} message-icon"></i>
<div class="message-text">${content}</div>
</div>
<div class="message-time">${now}</div>
`;
chatMessages.appendChild(messageDiv);
chatMessages.scrollTop = chatMessages.scrollHeight;
// 채팅 히스토리에 추가
chatHistory.push({
role: sender === 'user' ? 'user' : 'assistant',
content: content
});
}
// 타이핑 인디케이터 추가
function addTypingIndicator() {
const typingDiv = document.createElement('div');
typingDiv.className = 'message assistant';
typingDiv.id = 'typing-' + Date.now();
typingDiv.innerHTML = `
<div class="message-content">
<i class="fas fa-robot message-icon"></i>
<div class="typing-indicator">
<div class="typing-dot"></div>
<div class="typing-dot"></div>
<div class="typing-dot"></div>
</div>
</div>
`;
chatMessages.appendChild(typingDiv);
chatMessages.scrollTop = chatMessages.scrollHeight;
return typingDiv.id;
}
// 타이핑 인디케이터 제거
function removeTypingIndicator(typingId) {
const typingDiv = document.getElementById(typingId);
if (typingDiv) {
typingDiv.remove();
}
}
// 빠른 질문
function askQuickQuestion(question) {
messageInput.value = question;
sendMessage();
}
// 문서 검색
async function searchDocuments(event) {
if (event.key !== 'Enter') return;
const query = searchInput.value.trim();
if (!query) {
searchResults.innerHTML = '<p class="text-muted">검색어를 입력하세요</p>';
return;
}
try {
const response = await fetch('/search', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
query: query,
top_k: 5
})
});
const data = await response.json();
displaySearchResults(data.results);
} catch (error) {
console.error('Search error:', error);
searchResults.innerHTML = '<p class="text-muted">검색 중 오류가 발생했습니다</p>';
}
}
// 검색 결과 표시
function displaySearchResults(results) {
if (!results || results.length === 0) {
searchResults.innerHTML = '<p class="text-muted">검색 결과가 없습니다</p>';
return;
}
const resultsHtml = results.map(result => `
<div class="search-result-item" onclick="useSearchResult('${result.text.replace(/'/g, "\\'")}')">
<div class="search-result-title">문서 ID: ${result.id}</div>
<div class="search-result-snippet">${result.text.substring(0, 100)}...</div>
<small class="text-muted">유사도: ${(result.score * 100).toFixed(1)}%</small>
</div>
`).join('');
searchResults.innerHTML = resultsHtml;
}
// 검색 결과 활용
function useSearchResult(text) {
messageInput.value = `다음 내용에 대해 설명해주세요: "${text.substring(0, 50)}..."`;
}
// 대화 기록 삭제
function clearChat() {
if (!confirm('정말로 대화 기록을 모두 삭제하시겠습니까?')) return;
chatHistory = [];
chatMessages.innerHTML = `
<div class="message assistant">
<div class="message-content">
<i class="fas fa-robot message-icon"></i>
<div class="message-text">
안녕하세요! 저는 문서 기반 AI 어시스턴트입니다.
업로드된 문서들에 대해 질문하시면 답변해드리겠습니다.
</div>
</div>
<div class="message-time">${new Date().toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit' })}</div>
</div>
`;
}
// 대화 내용 내보내기
function exportChat() {
if (chatHistory.length === 0) {
showToast('내보낼 대화 내용이 없습니다.', 'info');
return;
}
const chatText = chatHistory.map(msg =>
`${msg.role === 'user' ? '사용자' : 'AI'}: ${msg.content}`
).join('\n\n');
const blob = new Blob([chatText], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `chat-export-${new Date().toISOString().slice(0, 10)}.txt`;
a.click();
URL.revokeObjectURL(url);
}
// 토스트 알림
function showToast(message, type = 'info') {
const toast = document.createElement('div');
toast.className = `alert alert-${type}`;
toast.textContent = message;
toast.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
z-index: 1001;
min-width: 300px;
animation: slideIn 0.3s ease;
`;
document.body.appendChild(toast);
setTimeout(() => {
toast.style.animation = 'slideOut 0.3s ease';
setTimeout(() => {
if (document.body.contains(toast)) {
document.body.removeChild(toast);
}
}, 300);
}, 3000);
}
</script>
{% endblock %}

348
templates/documents.html Normal file
View File

@@ -0,0 +1,348 @@
{% extends "base.html" %}
{% block title %}문서 관리 - AI 문서 처리 서버{% endblock %}
{% block content %}
<div class="card">
<div class="card-header">
<h1 class="card-title">
<i class="fas fa-folder-open"></i> 문서 관리
</h1>
<p class="card-subtitle">처리된 문서들을 확인하고 관리하세요</p>
</div>
<!-- 통계 요약 -->
<div class="grid grid-3 mb-4">
<div class="card">
<h3><i class="fas fa-file-alt"></i> 총 문서 수</h3>
<div class="stat-number">{{ documents|length }}</div>
</div>
<div class="card">
<h3><i class="fas fa-database"></i> 총 용량</h3>
<div class="stat-number" id="totalSize">계산 중...</div>
</div>
<div class="card">
<h3><i class="fas fa-calendar"></i> 최근 업로드</h3>
<div class="stat-number">
{% if documents %}
{{ documents[0].created.split(' ')[0] }}
{% else %}
없음
{% endif %}
</div>
</div>
</div>
<!-- 검색 및 필터 -->
<div class="search-controls mb-4">
<div class="grid grid-2">
<div>
<input type="text" class="form-control" id="searchInput"
placeholder="파일명으로 검색..." onkeyup="filterDocuments()">
</div>
<div>
<select class="form-control" id="sortSelect" onchange="sortDocuments()">
<option value="name">이름순</option>
<option value="date" selected>날짜순 (최신)</option>
<option value="size">크기순</option>
</select>
</div>
</div>
</div>
<!-- 문서 목록 -->
{% if documents %}
<div class="table-responsive">
<table class="table" id="documentsTable">
<thead>
<tr>
<th><i class="fas fa-file"></i> 파일명</th>
<th><i class="fas fa-calendar"></i> 생성일시</th>
<th><i class="fas fa-weight"></i> 크기</th>
<th><i class="fas fa-cogs"></i> 액션</th>
</tr>
</thead>
<tbody>
{% for doc in documents %}
<tr class="document-row" data-name="{{ doc.name|lower }}" data-date="{{ doc.created }}" data-size="{{ doc.size }}">
<td>
<div class="file-info">
<i class="fas fa-file-alt text-primary"></i>
<strong>{{ doc.name }}</strong>
<br>
<small class="text-muted">{{ formatFileSize(doc.size) }}</small>
</div>
</td>
<td>{{ doc.created }}</td>
<td>
<span class="file-size" data-bytes="{{ doc.size }}">{{ formatFileSize(doc.size) }}</span>
</td>
<td>
<div class="action-buttons">
<a href="/html/{{ doc.name }}" class="btn btn-sm btn-primary" target="_blank" title="HTML 보기">
<i class="fas fa-eye"></i>
</a>
<button onclick="openChatWithDoc('{{ doc.name }}')" class="btn btn-sm btn-success" title="이 문서로 대화하기">
<i class="fas fa-comments"></i>
</button>
<button onclick="downloadDoc('{{ doc.name }}')" class="btn btn-sm btn-secondary" title="다운로드">
<i class="fas fa-download"></i>
</button>
<button onclick="deleteDoc('{{ doc.name }}')" class="btn btn-sm btn-danger" title="삭제">
<i class="fas fa-trash"></i>
</button>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="empty-state">
<i class="fas fa-inbox fa-4x text-muted mb-3"></i>
<h3>처리된 문서가 없습니다</h3>
<p class="text-muted mb-4">파일을 업로드하여 AI 처리를 시작하세요.</p>
<a href="/upload" class="btn btn-primary btn-lg">
<i class="fas fa-cloud-upload-alt"></i> 첫 문서 업로드하기
</a>
</div>
{% endif %}
</div>
<!-- 파일 미리보기 모달 -->
<div class="modal" id="previewModal" style="display: none;">
<div class="modal-content">
<div class="modal-header">
<h3 id="previewTitle">문서 미리보기</h3>
<button onclick="closePreview()" class="btn btn-sm btn-secondary">
<i class="fas fa-times"></i> 닫기
</button>
</div>
<div class="modal-body">
<iframe id="previewFrame" style="width: 100%; height: 500px; border: none;"></iframe>
</div>
</div>
</div>
<style>
.search-controls {
background-color: #f8f9fa;
padding: 1.5rem;
border-radius: 8px;
border: 1px solid #e9ecef;
}
.file-info {
display: flex;
align-items: center;
gap: 0.5rem;
}
.file-info i {
font-size: 1.2rem;
}
.action-buttons {
display: flex;
gap: 0.25rem;
flex-wrap: wrap;
}
.btn-sm {
padding: 0.25rem 0.5rem;
font-size: 0.875rem;
}
.empty-state {
text-align: center;
padding: 4rem 2rem;
}
.modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
}
.modal-content {
background: white;
border-radius: 10px;
width: 90%;
max-width: 900px;
max-height: 90%;
overflow: hidden;
}
.modal-header {
padding: 1rem 1.5rem;
border-bottom: 1px solid #e9ecef;
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-body {
padding: 1.5rem;
}
.stat-number {
font-size: 2rem;
font-weight: bold;
color: #667eea;
margin-top: 0.5rem;
}
.mb-4 { margin-bottom: 2rem; }
.text-primary { color: #667eea; }
</style>
{% endblock %}
{% block scripts %}
<script>
let allDocuments = [];
// 페이지 로드 시 초기화
document.addEventListener('DOMContentLoaded', function() {
// 문서 데이터 수집
const rows = document.querySelectorAll('.document-row');
allDocuments = Array.from(rows).map(row => ({
element: row,
name: row.dataset.name,
date: new Date(row.dataset.date),
size: parseInt(row.dataset.size)
}));
// 총 용량 계산
const totalBytes = allDocuments.reduce((sum, doc) => sum + doc.size, 0);
document.getElementById('totalSize').textContent = formatFileSize(totalBytes);
// 기본 정렬 (날짜순)
sortDocuments();
});
// 파일 크기 포맷팅
function formatFileSize(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
// 문서 검색 필터링
function filterDocuments() {
const searchTerm = document.getElementById('searchInput').value.toLowerCase();
const rows = document.querySelectorAll('.document-row');
rows.forEach(row => {
const fileName = row.dataset.name;
if (fileName.includes(searchTerm)) {
row.style.display = '';
} else {
row.style.display = 'none';
}
});
}
// 문서 정렬
function sortDocuments() {
const sortBy = document.getElementById('sortSelect').value;
const tbody = document.querySelector('#documentsTable tbody');
allDocuments.sort((a, b) => {
switch (sortBy) {
case 'name':
return a.name.localeCompare(b.name);
case 'date':
return b.date - a.date; // 최신순
case 'size':
return b.size - a.size; // 큰 것부터
default:
return 0;
}
});
// DOM 재정렬
allDocuments.forEach(doc => {
tbody.appendChild(doc.element);
});
}
// 문서로 채팅 시작
function openChatWithDoc(fileName) {
const docName = fileName.replace('.html', '');
window.location.href = `/chat?doc=${encodeURIComponent(docName)}`;
}
// 문서 다운로드
function downloadDoc(fileName) {
const link = document.createElement('a');
link.href = `/html/${fileName}`;
link.download = fileName;
link.click();
}
// 문서 삭제
async function deleteDoc(fileName) {
if (!confirm(`정말로 "${fileName}" 문서를 삭제하시겠습니까?`)) {
return;
}
try {
// 실제로는 DELETE API를 구현해야 함
showToast('삭제 기능은 아직 구현되지 않았습니다.', 'info');
// TODO: DELETE /api/documents/{fileName} 구현
} catch (error) {
console.error('Delete error:', error);
showToast('삭제 중 오류가 발생했습니다.', 'danger');
}
}
// 미리보기 열기
function openPreview(fileName) {
document.getElementById('previewTitle').textContent = `${fileName} 미리보기`;
document.getElementById('previewFrame').src = `/html/${fileName}`;
document.getElementById('previewModal').style.display = 'flex';
}
// 미리보기 닫기
function closePreview() {
document.getElementById('previewModal').style.display = 'none';
document.getElementById('previewFrame').src = '';
}
// 토스트 알림 (main.js에서 가져옴)
function showToast(message, type = 'info') {
const toast = document.createElement('div');
toast.className = `alert alert-${type}`;
toast.textContent = message;
toast.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
z-index: 1001;
min-width: 300px;
animation: slideIn 0.3s ease;
`;
document.body.appendChild(toast);
setTimeout(() => {
toast.style.animation = 'slideOut 0.3s ease';
setTimeout(() => {
if (document.body.contains(toast)) {
document.body.removeChild(toast);
}
}, 300);
}, 3000);
}
</script>
{% endblock %}

229
templates/index.html Normal file
View File

@@ -0,0 +1,229 @@
{% extends "base.html" %}
{% block title %}AI 문서 처리 서버 - 대시보드{% endblock %}
{% block content %}
<div class="card">
<div class="card-header">
<h1 class="card-title">
<i class="fas fa-tachometer-alt"></i> 대시보드
</h1>
<p class="card-subtitle">AI 문서 처리 서버 현황을 확인하세요</p>
</div>
<!-- 시스템 상태 -->
<div class="grid grid-3">
<div class="card">
<h3><i class="fas fa-server"></i> 서버 상태</h3>
<div class="status status-success">
<i class="fas fa-check-circle"></i> 정상 운영
</div>
<p class="mt-2">
<strong>모델:</strong> {{ status.base_model }}<br>
<strong>임베딩:</strong> {{ status.embedding_model }}<br>
<strong>인덱스:</strong> {{ status.index_loaded }}개 문서
</p>
</div>
<div class="card">
<h3><i class="fas fa-upload"></i> 빠른 업로드</h3>
<div class="drop-zone-mini" onclick="window.location.href='/upload'">
<i class="fas fa-cloud-upload-alt"></i>
<p>파일을 업로드하려면 클릭하세요</p>
</div>
</div>
<div class="card">
<h3><i class="fas fa-search"></i> 빠른 검색</h3>
<form onsubmit="quickSearch(event)">
<input type="text" class="form-control" placeholder="문서 내용 검색..." id="quickSearchInput">
<button type="submit" class="btn btn-primary mt-2 w-100">
<i class="fas fa-search"></i> 검색
</button>
</form>
</div>
</div>
</div>
<!-- 최근 문서 -->
<div class="card">
<div class="card-header">
<h2 class="card-title">
<i class="fas fa-file-alt"></i> 최근 처리된 문서
</h2>
<a href="/documents" class="btn btn-secondary">모든 문서 보기</a>
</div>
{% if recent_documents %}
<div class="table-responsive">
<table class="table">
<thead>
<tr>
<th>문서 ID</th>
<th>처리 시간</th>
<th>청크 수</th>
<th>상태</th>
<th>액션</th>
</tr>
</thead>
<tbody>
{% for doc in recent_documents %}
<tr>
<td>{{ doc.id }}</td>
<td>{{ doc.created_at }}</td>
<td>{{ doc.chunks }}</td>
<td>
<span class="status status-success">
<i class="fas fa-check"></i> 완료
</span>
</td>
<td>
<a href="/html/{{ doc.html_file }}" class="btn btn-sm btn-primary" target="_blank">
<i class="fas fa-eye"></i> 보기
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center py-4">
<i class="fas fa-inbox fa-3x text-muted mb-3"></i>
<p class="text-muted">아직 처리된 문서가 없습니다.</p>
<a href="/upload" class="btn btn-primary">첫 문서 업로드하기</a>
</div>
{% endif %}
</div>
<!-- 통계 -->
<div class="grid grid-2">
<div class="card">
<h3><i class="fas fa-chart-bar"></i> 처리 통계</h3>
<div class="stats">
<div class="stat-item">
<span class="stat-number">{{ stats.total_documents }}</span>
<span class="stat-label">총 문서 수</span>
</div>
<div class="stat-item">
<span class="stat-number">{{ stats.total_chunks }}</span>
<span class="stat-label">총 청크 수</span>
</div>
<div class="stat-item">
<span class="stat-number">{{ stats.today_processed }}</span>
<span class="stat-label">오늘 처리</span>
</div>
</div>
</div>
<div class="card">
<h3><i class="fas fa-robot"></i> AI 챗봇</h3>
<p>문서 기반 질의응답을 시작하세요.</p>
<div class="quick-chat">
<input type="text" class="form-control mb-2" placeholder="질문을 입력하세요..." id="quickChatInput">
<button onclick="quickChat()" class="btn btn-success w-100">
<i class="fas fa-paper-plane"></i> 질문하기
</button>
</div>
<a href="/chat" class="btn btn-secondary w-100 mt-2">전체 채팅 화면으로</a>
</div>
</div>
<style>
.drop-zone-mini {
border: 2px dashed #ced4da;
border-radius: 10px;
padding: 2rem 1rem;
text-align: center;
cursor: pointer;
transition: all 0.3s;
background-color: #f8f9fa;
}
.drop-zone-mini:hover {
border-color: #667eea;
background-color: rgba(102, 126, 234, 0.05);
}
.drop-zone-mini i {
font-size: 2rem;
color: #6c757d;
margin-bottom: 0.5rem;
display: block;
}
.stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(100px, 1fr));
gap: 1rem;
margin-top: 1rem;
}
.stat-item {
text-align: center;
padding: 1rem;
background-color: #f8f9fa;
border-radius: 8px;
}
.stat-number {
display: block;
font-size: 2rem;
font-weight: bold;
color: #667eea;
}
.stat-label {
font-size: 0.85rem;
color: #6c757d;
}
.mt-2 { margin-top: 0.5rem; }
.mb-2 { margin-bottom: 0.5rem; }
.mb-3 { margin-bottom: 1rem; }
.py-4 { padding: 2rem 0; }
.w-100 { width: 100%; }
.text-center { text-align: center; }
.text-muted { color: #6c757d; }
.table-responsive { overflow-x: auto; }
.btn-sm { padding: 0.25rem 0.5rem; font-size: 0.875rem; }
</style>
{% endblock %}
{% block scripts %}
<script>
function quickSearch(event) {
event.preventDefault();
const query = document.getElementById('quickSearchInput').value;
if (query.trim()) {
window.location.href = `/search?q=${encodeURIComponent(query)}`;
}
}
function quickChat() {
const question = document.getElementById('quickChatInput').value;
if (question.trim()) {
// 간단한 챗봇 미리보기 (실제 구현은 /chat 페이지에서)
alert('채팅 기능은 "AI 챗봇" 페이지에서 이용하실 수 있습니다.');
window.location.href = '/chat';
}
}
// 페이지 로드 시 서버 상태 업데이트
document.addEventListener('DOMContentLoaded', function() {
// 실시간 상태 업데이트 (선택사항)
updateStatus();
});
async function updateStatus() {
try {
const response = await fetch('/health');
const data = await response.json();
// 상태 업데이트 로직
console.log('서버 상태:', data);
} catch (error) {
console.error('상태 확인 실패:', error);
}
}
</script>
{% endblock %}

395
templates/upload.html Normal file
View File

@@ -0,0 +1,395 @@
{% extends "base.html" %}
{% block title %}파일 업로드 - AI 문서 처리 서버{% endblock %}
{% block content %}
<div class="card">
<div class="card-header">
<h1 class="card-title">
<i class="fas fa-cloud-upload-alt"></i> 파일 업로드
</h1>
<p class="card-subtitle">PDF 또는 TXT 파일을 업로드하여 AI 처리를 시작하세요</p>
</div>
<form id="uploadForm" enctype="multipart/form-data">
<div class="grid grid-2">
<!-- 파일 선택 영역 -->
<div>
<h3><i class="fas fa-file"></i> 파일 선택</h3>
<div class="drop-zone" id="dropZone">
<i class="fas fa-cloud-upload-alt"></i>
<h4>파일을 드래그하거나 클릭하여 선택</h4>
<p>PDF, TXT 파일 지원 (최대 200MB)</p>
<input type="file" id="fileInput" name="file" accept=".pdf,.txt" style="display: none;">
<button type="button" class="btn btn-primary mt-2" onclick="document.getElementById('fileInput').click()">
<i class="fas fa-folder-open"></i> 파일 선택
</button>
</div>
<div id="fileInfo" class="file-info" style="display: none;">
<h4><i class="fas fa-file-check"></i> 선택된 파일</h4>
<div id="fileDetails"></div>
<button type="button" class="btn btn-secondary btn-sm mt-2" onclick="clearFile()">
<i class="fas fa-times"></i> 다른 파일 선택
</button>
</div>
</div>
<!-- 옵션 설정 -->
<div>
<h3><i class="fas fa-cogs"></i> 처리 옵션</h3>
<div class="form-group">
<label class="form-label" for="docId">문서 ID</label>
<input type="text" class="form-control" id="docId" name="doc_id"
placeholder="예: report-2025-001" required>
<small class="text-muted">고유한 문서 식별자를 입력하세요</small>
</div>
<div class="form-group">
<div class="checkbox-group">
<label class="checkbox-label">
<input type="checkbox" id="generateHtml" name="generate_html" checked>
<i class="fas fa-code"></i> HTML 파일 생성
</label>
<small class="text-muted">읽기 쉬운 HTML 형태로 변환합니다</small>
</div>
</div>
<div class="form-group">
<div class="checkbox-group">
<label class="checkbox-label">
<input type="checkbox" id="translate" name="translate">
<i class="fas fa-language"></i> 한국어로 번역
</label>
<small class="text-muted">영어 문서를 한국어로 번역합니다</small>
</div>
</div>
<div class="form-group" id="targetLangGroup" style="display: none;">
<label class="form-label" for="targetLanguage">번역 언어</label>
<select class="form-control" id="targetLanguage" name="target_language">
<option value="ko">한국어</option>
<option value="en">영어</option>
<option value="ja">일본어</option>
<option value="zh">중국어</option>
</select>
</div>
<div class="form-group">
<div class="checkbox-group">
<label class="checkbox-label">
<input type="checkbox" id="summarize" name="summarize">
<i class="fas fa-compress-alt"></i> 요약 생성
</label>
<small class="text-muted">긴 문서를 요약하여 처리합니다</small>
</div>
</div>
<div class="form-group" id="summaryGroup" style="display: none;">
<label class="form-label" for="summarySentences">요약 문장 수</label>
<input type="number" class="form-control" id="summarySentences"
name="summary_sentences" value="5" min="3" max="10">
</div>
</div>
</div>
<div class="upload-actions">
<button type="submit" class="btn btn-success btn-lg" id="uploadBtn" disabled>
<i class="fas fa-rocket"></i> 업로드 및 처리 시작
</button>
<button type="button" class="btn btn-secondary" onclick="resetForm()">
<i class="fas fa-redo"></i> 초기화
</button>
</div>
</form>
<!-- 진행 상황 -->
<div id="progressSection" style="display: none;">
<h3><i class="fas fa-tasks"></i> 처리 진행 상황</h3>
<div class="progress">
<div class="progress-bar" id="progressBar" style="width: 0%"></div>
</div>
<div id="progressText">준비 중...</div>
</div>
<!-- 결과 -->
<div id="resultSection" style="display: none;">
<h3><i class="fas fa-check-circle"></i> 처리 완료</h3>
<div id="resultContent"></div>
</div>
</div>
<style>
.checkbox-group {
margin-bottom: 0.5rem;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
font-weight: 500;
}
.checkbox-label input[type="checkbox"] {
margin: 0;
}
.file-info {
margin-top: 1rem;
padding: 1rem;
background-color: #f8f9fa;
border-radius: 8px;
border: 1px solid #e9ecef;
}
.file-details {
display: grid;
gap: 0.5rem;
margin-top: 0.5rem;
}
.upload-actions {
margin-top: 2rem;
padding-top: 2rem;
border-top: 1px solid #e9ecef;
display: flex;
gap: 1rem;
justify-content: center;
}
#progressSection {
margin-top: 2rem;
padding: 1.5rem;
background-color: #f8f9fa;
border-radius: 8px;
border: 1px solid #e9ecef;
}
#resultSection {
margin-top: 2rem;
padding: 1.5rem;
background-color: #d4edda;
border-radius: 8px;
border: 1px solid #c3e6cb;
}
.btn-lg {
padding: 1rem 2rem;
font-size: 1.1rem;
}
</style>
{% endblock %}
{% block scripts %}
<script>
let selectedFile = null;
// DOM 요소들
const dropZone = document.getElementById('dropZone');
const fileInput = document.getElementById('fileInput');
const fileInfo = document.getElementById('fileInfo');
const fileDetails = document.getElementById('fileDetails');
const uploadBtn = document.getElementById('uploadBtn');
const uploadForm = document.getElementById('uploadForm');
const progressSection = document.getElementById('progressSection');
const progressBar = document.getElementById('progressBar');
const progressText = document.getElementById('progressText');
const resultSection = document.getElementById('resultSection');
const resultContent = document.getElementById('resultContent');
// 체크박스 이벤트
document.getElementById('translate').addEventListener('change', function() {
const targetLangGroup = document.getElementById('targetLangGroup');
targetLangGroup.style.display = this.checked ? 'block' : 'none';
});
document.getElementById('summarize').addEventListener('change', function() {
const summaryGroup = document.getElementById('summaryGroup');
summaryGroup.style.display = this.checked ? 'block' : 'none';
});
// 드래그 앤 드롭 이벤트
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
dropZone.classList.add('dragover');
});
dropZone.addEventListener('dragleave', () => {
dropZone.classList.remove('dragover');
});
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.classList.remove('dragover');
const files = e.dataTransfer.files;
if (files.length > 0) {
handleFileSelect(files[0]);
}
});
// 파일 선택 이벤트
fileInput.addEventListener('change', (e) => {
if (e.target.files.length > 0) {
handleFileSelect(e.target.files[0]);
}
});
// 파일 처리 함수
function handleFileSelect(file) {
// 파일 타입 검증
const allowedTypes = ['application/pdf', 'text/plain'];
if (!allowedTypes.includes(file.type) &&
!file.name.toLowerCase().endsWith('.pdf') &&
!file.name.toLowerCase().endsWith('.txt')) {
showToast('PDF 또는 TXT 파일만 업로드할 수 있습니다.', 'danger');
return;
}
// 파일 크기 검증 (200MB)
if (file.size > 200 * 1024 * 1024) {
showToast('파일 크기는 200MB를 초과할 수 없습니다.', 'danger');
return;
}
selectedFile = file;
// 파일 정보 표시
fileDetails.innerHTML = `
<div class="file-details">
<div><strong>파일명:</strong> ${file.name}</div>
<div><strong>크기:</strong> ${formatFileSize(file.size)}</div>
<div><strong>타입:</strong> ${file.type || '알 수 없음'}</div>
</div>
`;
dropZone.style.display = 'none';
fileInfo.style.display = 'block';
uploadBtn.disabled = false;
// 문서 ID 자동 생성
const timestamp = new Date().toISOString().slice(0, 10);
const baseName = file.name.replace(/\.[^/.]+$/, "");
document.getElementById('docId').value = `${baseName}-${timestamp}`;
}
// 파일 선택 취소
function clearFile() {
selectedFile = null;
fileInput.value = '';
dropZone.style.display = 'block';
fileInfo.style.display = 'none';
uploadBtn.disabled = true;
progressSection.style.display = 'none';
resultSection.style.display = 'none';
}
// 폼 초기화
function resetForm() {
clearFile();
uploadForm.reset();
document.getElementById('generateHtml').checked = true;
document.getElementById('targetLangGroup').style.display = 'none';
document.getElementById('summaryGroup').style.display = 'none';
}
// 폼 제출
uploadForm.addEventListener('submit', async (e) => {
e.preventDefault();
if (!selectedFile) {
showToast('파일을 선택해주세요.', 'danger');
return;
}
const formData = new FormData();
formData.append('file', selectedFile);
formData.append('doc_id', document.getElementById('docId').value);
formData.append('generate_html', document.getElementById('generateHtml').checked);
formData.append('translate', document.getElementById('translate').checked);
formData.append('target_language', document.getElementById('targetLanguage').value);
// 진행 상황 표시
progressSection.style.display = 'block';
resultSection.style.display = 'none';
setLoading(uploadBtn);
try {
updateProgress(20, '파일 업로드 중...');
const response = await fetch('/pipeline/ingest_file', {
method: 'POST',
headers: {
'X-API-Key': '{{ api_key }}'
},
body: formData
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
updateProgress(80, '처리 중...');
const result = await response.json();
updateProgress(100, '완료!');
// 결과 표시
setTimeout(() => {
showResult(result);
}, 500);
showToast('파일이 성공적으로 처리되었습니다!', 'success');
} catch (error) {
console.error('Upload error:', error);
showToast('업로드 중 오류가 발생했습니다: ' + error.message, 'danger');
progressSection.style.display = 'none';
} finally {
uploadBtn.disabled = false;
uploadBtn.innerHTML = '<i class="fas fa-rocket"></i> 업로드 및 처리 시작';
}
});
function updateProgress(percent, text) {
progressBar.style.width = percent + '%';
progressText.textContent = text;
}
function showResult(result) {
resultContent.innerHTML = `
<div class="result-details">
<h4><i class="fas fa-info-circle"></i> 처리 결과</h4>
<div class="grid grid-2" style="margin-top: 1rem;">
<div>
<strong>문서 ID:</strong> ${result.doc_id}<br>
<strong>처리된 청크:</strong> ${result.added}개<br>
<strong>전체 청크:</strong> ${result.chunks}
</div>
<div class="result-actions">
${result.html_path ? `
<a href="/html/${result.html_path.split('/').pop()}"
class="btn btn-primary" target="_blank">
<i class="fas fa-eye"></i> HTML 보기
</a>
` : ''}
<a href="/documents" class="btn btn-secondary">
<i class="fas fa-list"></i> 문서 목록
</a>
<a href="/chat" class="btn btn-success">
<i class="fas fa-comments"></i> 문서로 대화하기
</a>
</div>
</div>
</div>
`;
resultSection.style.display = 'block';
progressSection.style.display = 'none';
}
</script>
{% endblock %}