feat: 초기 프로젝트 구조 설정 및 소스 코드 추가
This commit is contained in:
47
.gitignore
vendored
Normal file
47
.gitignore
vendored
Normal file
@@ -0,0 +1,47 @@
|
||||
# =========================
|
||||
# Python 가상환경
|
||||
# =========================
|
||||
# 각 개발자의 로컬 환경에 따라 내용이 다르며,
|
||||
# 용량이 매우 크기 때문에 Git으로 관리하지 않습니다.
|
||||
# 대신 'requirements.txt' 파일을 통해 필요한 라이브러리를 관리합니다.
|
||||
nllb_env/
|
||||
|
||||
# =========================
|
||||
# AI 모델 파일
|
||||
# =========================
|
||||
# 수 GB에 달하는 매우 큰 파일들입니다.
|
||||
# Git은 대용량 파일을 관리하기에 적합하지 않으므로 제외합니다.
|
||||
# 모델은 별도의 방법(예: 다운로드 스크립트)으로 관리해야 합니다.
|
||||
models/
|
||||
|
||||
# =========================
|
||||
# 데이터 파일
|
||||
# =========================
|
||||
# 원본 데이터나 학습 데이터는 용량이 클 수 있으므로 제외합니다.
|
||||
data/
|
||||
|
||||
# =========================
|
||||
# 실행 결과물
|
||||
# =========================
|
||||
# 코드를 실행하면 자동으로 생성되는 출력 파일들입니다.
|
||||
# 소스 코드가 아니므로 버전 관리 대상에서 제외합니다.
|
||||
output/
|
||||
|
||||
# =========================
|
||||
# Python 캐시 파일
|
||||
# =========================
|
||||
# Python 인터프리터가 실행 속도 향상을 위해 자동으로 생성하는 파일들입니다.
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# =========================
|
||||
# macOS 시스템 파일
|
||||
# =========================
|
||||
# macOS의 Finder가 자동으로 생성하는 시스템 파일입니다.
|
||||
.DS_Store
|
||||
|
||||
# =========================
|
||||
# IDE 및 에디터 설정
|
||||
# =========================
|
||||
.idea/
|
||||
.vscode/
|
||||
88
check_installation.py
Normal file
88
check_installation.py
Normal file
@@ -0,0 +1,88 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
환경 설정 확인 스크립트
|
||||
"""
|
||||
|
||||
def check_installation():
|
||||
print("🔍 설치된 패키지 확인 중...")
|
||||
|
||||
try:
|
||||
import torch
|
||||
print(f"✅ PyTorch: {torch.__version__}")
|
||||
|
||||
# Apple Silicon MPS 확인
|
||||
if torch.backends.mps.is_available():
|
||||
print("✅ Apple Silicon MPS 가속 사용 가능")
|
||||
else:
|
||||
print("⚠️ MPS 가속 사용 불가 (CPU 모드)")
|
||||
|
||||
except ImportError:
|
||||
print("❌ PyTorch 설치 실패")
|
||||
return False
|
||||
|
||||
try:
|
||||
import transformers
|
||||
print(f"✅ Transformers: {transformers.__version__}")
|
||||
except ImportError:
|
||||
print("❌ Transformers 설치 실패")
|
||||
return False
|
||||
|
||||
try:
|
||||
import sentencepiece
|
||||
print("✅ SentencePiece 설치 완료")
|
||||
except ImportError:
|
||||
print("❌ SentencePiece 설치 실패")
|
||||
return False
|
||||
|
||||
try:
|
||||
import accelerate
|
||||
print("✅ Accelerate 설치 완료")
|
||||
except ImportError:
|
||||
print("❌ Accelerate 설치 실패")
|
||||
return False
|
||||
|
||||
# 문서 처리 라이브러리 확인
|
||||
doc_libs = []
|
||||
try:
|
||||
import PyPDF2
|
||||
doc_libs.append("PyPDF2")
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try:
|
||||
import pdfplumber
|
||||
doc_libs.append("pdfplumber")
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try:
|
||||
from docx import Document
|
||||
doc_libs.append("python-docx")
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
if doc_libs:
|
||||
print(f"✅ 문서 처리: {', '.join(doc_libs)}")
|
||||
else:
|
||||
print("⚠️ 문서 처리 라이브러리 설치 필요")
|
||||
|
||||
# 시스템 정보
|
||||
print(f"\n📊 시스템 정보:")
|
||||
print(f" Python 버전: {torch.version.python}")
|
||||
if torch.backends.mps.is_available():
|
||||
print(f" 디바이스: Apple Silicon (MPS)")
|
||||
else:
|
||||
print(f" 디바이스: CPU")
|
||||
|
||||
return True
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("🚀 NLLB 번역 시스템 환경 확인")
|
||||
print("=" * 40)
|
||||
|
||||
if check_installation():
|
||||
print("\n🎉 환경 설정 완료!")
|
||||
print("다음 단계: NLLB 모델 다운로드")
|
||||
else:
|
||||
print("\n❌ 환경 설정 실패")
|
||||
print("패키지 재설치 필요")
|
||||
28
config/settings.json
Normal file
28
config/settings.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"models": {
|
||||
"translation": "facebook/nllb-200-3.3B",
|
||||
"summarization": "ainize/kobart-news",
|
||||
"embedding": "nomic-embed-text"
|
||||
},
|
||||
"translation": {
|
||||
"max_length": 512,
|
||||
"num_beams": 4,
|
||||
"early_stopping": true,
|
||||
"batch_size": 4
|
||||
},
|
||||
"summarization": {
|
||||
"max_length": 150,
|
||||
"min_length": 30,
|
||||
"num_beams": 4
|
||||
},
|
||||
"processing": {
|
||||
"chunk_size": 500,
|
||||
"overlap": 50,
|
||||
"concurrent_chunks": 3
|
||||
},
|
||||
"output": {
|
||||
"html_template": "modern",
|
||||
"include_toc": true,
|
||||
"include_summary": true
|
||||
}
|
||||
}
|
||||
32
requirements.txt
Normal file
32
requirements.txt
Normal file
@@ -0,0 +1,32 @@
|
||||
# AI 모델 관련
|
||||
torch>=2.0.0
|
||||
transformers>=4.30.0
|
||||
sentencepiece>=0.1.99
|
||||
accelerate>=0.20.0
|
||||
datasets>=2.12.0
|
||||
|
||||
# PDF 처리
|
||||
PyPDF2>=3.0.1
|
||||
pdfplumber>=0.9.0
|
||||
|
||||
# 문서 처리
|
||||
python-docx>=0.8.11
|
||||
|
||||
# 언어 감지
|
||||
langdetect>=1.0.9
|
||||
|
||||
# 웹 프레임워크
|
||||
fastapi>=0.100.0
|
||||
uvicorn[standard]>=0.22.0
|
||||
python-multipart>=0.0.6
|
||||
jinja2>=3.1.2
|
||||
aiofiles>=23.1.0
|
||||
|
||||
# 유틸리티
|
||||
tqdm>=4.65.0
|
||||
numpy>=1.24.0
|
||||
pandas>=2.0.0
|
||||
|
||||
# 추가 도구
|
||||
requests>=2.31.0
|
||||
pathlib2>=2.3.7
|
||||
181
src/download_kobart.py
Executable file
181
src/download_kobart.py
Executable file
@@ -0,0 +1,181 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
KoBART 한국어 요약 모델 다운로드 및 테스트
|
||||
"""
|
||||
|
||||
import torch
|
||||
import time
|
||||
from transformers import BartForConditionalGeneration, AutoTokenizer
|
||||
from pathlib import Path
|
||||
|
||||
def download_kobart_model():
|
||||
print("🔄 KoBART 한국어 요약 모델 다운로드 시작...")
|
||||
print("📊 모델 크기: ~500MB, 다운로드 시간 3-5분 예상")
|
||||
|
||||
model_name = "ainize/kobart-news"
|
||||
|
||||
# 로컬 모델 저장 경로
|
||||
model_dir = Path("models/kobart-news")
|
||||
model_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
try:
|
||||
# 1. 토크나이저 다운로드
|
||||
print("\n📥 1/2: 토크나이저 다운로드 중...")
|
||||
tokenizer = AutoTokenizer.from_pretrained(
|
||||
model_name,
|
||||
cache_dir=str(model_dir)
|
||||
)
|
||||
print("✅ 토크나이저 다운로드 완료")
|
||||
|
||||
# 2. 모델 다운로드
|
||||
print("\n📥 2/2: KoBART 모델 다운로드 중...")
|
||||
start_time = time.time()
|
||||
|
||||
model = BartForConditionalGeneration.from_pretrained(
|
||||
model_name,
|
||||
cache_dir=str(model_dir),
|
||||
torch_dtype=torch.float16,
|
||||
low_cpu_mem_usage=True
|
||||
)
|
||||
|
||||
download_time = time.time() - start_time
|
||||
print(f"✅ 모델 다운로드 완료 ({download_time/60:.1f}분 소요)")
|
||||
|
||||
return model, tokenizer
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 다운로드 실패: {e}")
|
||||
return None, None
|
||||
|
||||
def test_kobart_model(model, tokenizer):
|
||||
print("\n🧪 KoBART 요약 모델 테스트...")
|
||||
|
||||
# Apple Silicon 최적화
|
||||
if torch.backends.mps.is_available():
|
||||
device = torch.device("mps")
|
||||
model = model.to(device)
|
||||
print("🚀 Apple Silicon MPS 가속 사용")
|
||||
else:
|
||||
device = torch.device("cpu")
|
||||
print("💻 CPU 모드 사용")
|
||||
|
||||
def summarize_text(text, max_length=150, min_length=30):
|
||||
print(f"\n📝 요약 테스트:")
|
||||
print(f"원문 ({len(text)}자):")
|
||||
print(f"{text}")
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
# 텍스트 토큰화
|
||||
inputs = tokenizer(
|
||||
text,
|
||||
return_tensors="pt",
|
||||
max_length=1024,
|
||||
truncation=True,
|
||||
padding=True
|
||||
).to(device)
|
||||
|
||||
# 요약 생성
|
||||
with torch.no_grad():
|
||||
summary_ids = model.generate(
|
||||
**inputs,
|
||||
max_length=max_length,
|
||||
min_length=min_length,
|
||||
num_beams=4,
|
||||
early_stopping=True,
|
||||
no_repeat_ngram_size=2,
|
||||
length_penalty=2.0
|
||||
)
|
||||
|
||||
# 결과 디코딩
|
||||
summary = tokenizer.decode(summary_ids[0], skip_special_tokens=True)
|
||||
|
||||
process_time = time.time() - start_time
|
||||
print(f"\n요약 ({len(summary)}자):")
|
||||
print(f"{summary}")
|
||||
print(f"소요 시간: {process_time:.2f}초")
|
||||
print(f"압축률: {len(summary)/len(text)*100:.1f}%")
|
||||
|
||||
return summary, process_time
|
||||
|
||||
try:
|
||||
# 테스트 케이스 1: 기술 문서
|
||||
tech_text = """
|
||||
인공지능 기술이 급속히 발전하면서 우리의 일상생활과 업무 환경에 큰 변화를 가져오고 있습니다.
|
||||
특히 자연어 처리 분야에서는 번역, 요약, 질의응답 등의 기술이 크게 향상되어 실용적인 수준에
|
||||
도달했습니다. 기계학습 알고리즘은 대량의 데이터를 학습하여 패턴을 파악하고, 이를 바탕으로
|
||||
새로운 입력에 대해 예측이나 분류를 수행합니다. 딥러닝 기술의 발전으로 이미지 인식, 음성 인식,
|
||||
자연어 이해 등의 성능이 인간 수준에 근접하거나 이를 넘어서는 경우도 생겨나고 있습니다.
|
||||
이러한 기술들은 의료, 금융, 교육, 엔터테인먼트 등 다양한 분야에서 활용되고 있으며,
|
||||
앞으로도 더 많은 혁신을 가져올 것으로 예상됩니다.
|
||||
"""
|
||||
|
||||
summarize_text(tech_text.strip())
|
||||
|
||||
# 테스트 케이스 2: 뉴스 스타일
|
||||
news_text = """
|
||||
최근 발표된 연구에 따르면 인공지능을 활용한 번역 시스템의 정확도가 크게 향상되었다고 합니다.
|
||||
특히 한국어와 영어, 일본어 간의 번역에서 기존 시스템 대비 20% 이상의 성능 개선을 보였습니다.
|
||||
연구팀은 대규모 언어 모델과 특화된 번역 모델을 결합하여 문맥을 더 정확히 이해하고
|
||||
자연스러운 번역을 생성할 수 있게 되었다고 설명했습니다. 이번 기술은 개인 사용자뿐만 아니라
|
||||
기업의 글로벌 비즈니스에도 큰 도움이 될 것으로 기대됩니다.
|
||||
"""
|
||||
|
||||
summarize_text(news_text.strip())
|
||||
|
||||
print(f"\n✅ KoBART 요약 모델 테스트 완료!")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 요약 테스트 실패: {e}")
|
||||
return False
|
||||
|
||||
def check_total_model_size():
|
||||
"""전체 모델 크기 확인"""
|
||||
model_dir = Path("models")
|
||||
if model_dir.exists():
|
||||
import subprocess
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["du", "-sh", str(model_dir)],
|
||||
capture_output=True,
|
||||
text=True
|
||||
)
|
||||
if result.returncode == 0:
|
||||
size = result.stdout.strip().split()[0]
|
||||
print(f"📊 전체 모델 크기: {size}")
|
||||
except:
|
||||
print("📊 모델 크기 확인 불가")
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("🚀 KoBART 한국어 요약 모델 설치")
|
||||
print("="*50)
|
||||
|
||||
# 기존 모델 확인
|
||||
model_dir = Path("models/kobart-news")
|
||||
if model_dir.exists() and any(model_dir.iterdir()):
|
||||
print("✅ 기존 KoBART 모델 발견, 로딩 시도...")
|
||||
try:
|
||||
tokenizer = AutoTokenizer.from_pretrained(str(model_dir))
|
||||
model = BartForConditionalGeneration.from_pretrained(
|
||||
str(model_dir),
|
||||
torch_dtype=torch.float16,
|
||||
low_cpu_mem_usage=True
|
||||
)
|
||||
print("✅ 기존 모델 로딩 완료")
|
||||
except:
|
||||
print("⚠️ 기존 모델 손상, 재다운로드 필요")
|
||||
model, tokenizer = download_kobart_model()
|
||||
else:
|
||||
model, tokenizer = download_kobart_model()
|
||||
|
||||
if model is not None and tokenizer is not None:
|
||||
print("\n🧪 요약 모델 테스트 시작...")
|
||||
if test_kobart_model(model, tokenizer):
|
||||
check_total_model_size()
|
||||
print("\n🎉 KoBART 요약 모델 설치 및 테스트 완료!")
|
||||
print("📝 다음 단계: 통합 번역 시스템 구축")
|
||||
else:
|
||||
print("\n❌ 요약 모델 테스트 실패")
|
||||
else:
|
||||
print("\n❌ KoBART 모델 다운로드 실패")
|
||||
167
src/download_korean_summarizer.py
Executable file
167
src/download_korean_summarizer.py
Executable file
@@ -0,0 +1,167 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
안정적인 한국어 요약 모델
|
||||
"""
|
||||
|
||||
import torch
|
||||
import time
|
||||
from transformers import PreTrainedTokenizerFast, BartForConditionalGeneration
|
||||
from pathlib import Path
|
||||
|
||||
def download_korean_summarizer():
|
||||
print("🔄 한국어 요약 모델 다운로드 (대안)")
|
||||
|
||||
# 더 안정적인 모델 사용
|
||||
model_name = "gogamza/kobart-summarization"
|
||||
|
||||
model_dir = Path("models/korean-summarizer")
|
||||
model_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
try:
|
||||
print("📥 토크나이저 다운로드 중...")
|
||||
tokenizer = PreTrainedTokenizerFast.from_pretrained(
|
||||
model_name,
|
||||
cache_dir=str(model_dir)
|
||||
)
|
||||
print("✅ 토크나이저 다운로드 완료")
|
||||
|
||||
print("📥 모델 다운로드 중...")
|
||||
start_time = time.time()
|
||||
|
||||
model = BartForConditionalGeneration.from_pretrained(
|
||||
model_name,
|
||||
cache_dir=str(model_dir),
|
||||
torch_dtype=torch.float16,
|
||||
low_cpu_mem_usage=True
|
||||
)
|
||||
|
||||
download_time = time.time() - start_time
|
||||
print(f"✅ 모델 다운로드 완료 ({download_time/60:.1f}분 소요)")
|
||||
|
||||
return model, tokenizer
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 다운로드 실패: {e}")
|
||||
|
||||
# 마지막 대안: 간단한 T5 모델
|
||||
print("\n🔄 더 간단한 모델로 재시도...")
|
||||
try:
|
||||
from transformers import T5Tokenizer, T5ForConditionalGeneration
|
||||
|
||||
alt_model_name = "t5-small"
|
||||
print(f"📥 {alt_model_name} 다운로드 중...")
|
||||
|
||||
tokenizer = T5Tokenizer.from_pretrained(alt_model_name)
|
||||
model = T5ForConditionalGeneration.from_pretrained(
|
||||
alt_model_name,
|
||||
torch_dtype=torch.float16,
|
||||
low_cpu_mem_usage=True
|
||||
)
|
||||
|
||||
print("✅ T5 백업 모델 다운로드 완료")
|
||||
return model, tokenizer
|
||||
|
||||
except Exception as e2:
|
||||
print(f"❌ 백업 모델도 실패: {e2}")
|
||||
return None, None
|
||||
|
||||
def test_summarizer(model, tokenizer, model_type="kobart"):
|
||||
print(f"\n🧪 {model_type} 요약 모델 테스트...")
|
||||
|
||||
# Apple Silicon 최적화
|
||||
if torch.backends.mps.is_available():
|
||||
device = torch.device("mps")
|
||||
model = model.to(device)
|
||||
print("🚀 Apple Silicon MPS 가속 사용")
|
||||
else:
|
||||
device = torch.device("cpu")
|
||||
print("💻 CPU 모드 사용")
|
||||
|
||||
def summarize_korean_text(text):
|
||||
print(f"\n📝 한국어 요약 테스트:")
|
||||
print(f"원문 ({len(text)}자):")
|
||||
print(f"{text[:200]}...")
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
if model_type == "t5":
|
||||
# T5 모델용 프롬프트
|
||||
input_text = f"summarize: {text}"
|
||||
else:
|
||||
# KoBART 모델용
|
||||
input_text = text
|
||||
|
||||
# 텍스트 토큰화
|
||||
inputs = tokenizer(
|
||||
input_text,
|
||||
return_tensors="pt",
|
||||
max_length=1024,
|
||||
truncation=True,
|
||||
padding=True
|
||||
).to(device)
|
||||
|
||||
# 요약 생성
|
||||
with torch.no_grad():
|
||||
summary_ids = model.generate(
|
||||
**inputs,
|
||||
max_length=150,
|
||||
min_length=30,
|
||||
num_beams=4,
|
||||
early_stopping=True,
|
||||
no_repeat_ngram_size=2,
|
||||
length_penalty=1.5
|
||||
)
|
||||
|
||||
# 결과 디코딩
|
||||
summary = tokenizer.decode(summary_ids[0], skip_special_tokens=True)
|
||||
|
||||
process_time = time.time() - start_time
|
||||
print(f"\n📋 요약 결과 ({len(summary)}자):")
|
||||
print(f"{summary}")
|
||||
print(f"⏱️ 처리 시간: {process_time:.2f}초")
|
||||
print(f"📊 압축률: {len(summary)/len(text)*100:.1f}%")
|
||||
|
||||
return summary
|
||||
|
||||
try:
|
||||
# 한국어 테스트 텍스트
|
||||
korean_text = """
|
||||
인공지능과 기계학습 기술이 급속도로 발전하면서 우리의 일상생활과 업무 환경에
|
||||
혁신적인 변화를 가져오고 있습니다. 특히 자연어 처리 분야에서는 번역, 요약,
|
||||
대화형 AI 등의 기술이 실용적인 수준에 도달하여 다양한 서비스에 적용되고 있습니다.
|
||||
기계학습 알고리즘은 대량의 텍스트 데이터를 학습하여 언어의 패턴과 의미를 이해하고,
|
||||
이를 바탕으로 인간과 유사한 수준의 언어 처리 능력을 보여주고 있습니다.
|
||||
딥러닝 기술의 발전으로 번역의 정확도가 크게 향상되었으며, 실시간 번역 서비스도
|
||||
일상적으로 사용할 수 있게 되었습니다. 앞으로 이러한 기술들은 교육, 의료,
|
||||
비즈니스 등 더 많은 분야에서 활용될 것으로 예상되며, 언어 장벽을 허물어
|
||||
글로벌 소통을 더욱 원활하게 만들 것입니다.
|
||||
"""
|
||||
|
||||
summarize_korean_text(korean_text.strip())
|
||||
|
||||
print(f"\n✅ 요약 모델 테스트 완료!")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 요약 테스트 실패: {e}")
|
||||
return False
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("🚀 한국어 요약 모델 설치 (대안)")
|
||||
print("="*50)
|
||||
|
||||
model, tokenizer = download_korean_summarizer()
|
||||
|
||||
if model is not None and tokenizer is not None:
|
||||
# 모델 타입 판단
|
||||
model_type = "t5" if "t5" in str(type(model)).lower() else "kobart"
|
||||
|
||||
print(f"\n🧪 {model_type} 모델 테스트 시작...")
|
||||
if test_summarizer(model, tokenizer, model_type):
|
||||
print("\n🎉 한국어 요약 모델 설치 및 테스트 완료!")
|
||||
print("📝 다음 단계: 통합 번역 시스템 구축")
|
||||
else:
|
||||
print("\n❌ 요약 모델 테스트 실패")
|
||||
else:
|
||||
print("\n❌ 모든 요약 모델 다운로드 실패")
|
||||
print("📝 요약 기능 없이 번역만으로 진행 가능")
|
||||
174
src/download_nllb.py
Executable file
174
src/download_nllb.py
Executable file
@@ -0,0 +1,174 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
NLLB-200-3.3B 모델 다운로드 및 초기 테스트
|
||||
"""
|
||||
|
||||
import os
|
||||
import time
|
||||
import torch
|
||||
from transformers import AutoTokenizer, AutoModelForSeq2SeqLM
|
||||
from pathlib import Path
|
||||
|
||||
def download_nllb_model():
|
||||
print("🔄 NLLB-200-3.3B 모델 다운로드 시작...")
|
||||
print("⚠️ 모델 크기: ~7GB, 다운로드 시간 10-15분 예상")
|
||||
|
||||
model_name = "facebook/nllb-200-3.3B"
|
||||
|
||||
# 로컬 모델 저장 경로
|
||||
model_dir = Path("models/nllb-200-3.3B")
|
||||
model_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
try:
|
||||
# 1. 토크나이저 다운로드
|
||||
print("\n📥 1/2: 토크나이저 다운로드 중...")
|
||||
tokenizer = AutoTokenizer.from_pretrained(
|
||||
model_name,
|
||||
cache_dir=str(model_dir),
|
||||
local_files_only=False
|
||||
)
|
||||
print("✅ 토크나이저 다운로드 완료")
|
||||
|
||||
# 2. 모델 다운로드 (시간이 오래 걸림)
|
||||
print("\n📥 2/2: 모델 다운로드 중...")
|
||||
print("⏳ 진행률을 확인하려면 별도 터미널에서 다음 명령어 실행:")
|
||||
print(f" du -sh {model_dir}/models--facebook--nllb-200-3.3B")
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
model = AutoModelForSeq2SeqLM.from_pretrained(
|
||||
model_name,
|
||||
cache_dir=str(model_dir),
|
||||
local_files_only=False,
|
||||
torch_dtype=torch.float16, # 메모리 절약
|
||||
low_cpu_mem_usage=True
|
||||
)
|
||||
|
||||
download_time = time.time() - start_time
|
||||
print(f"✅ 모델 다운로드 완료 ({download_time/60:.1f}분 소요)")
|
||||
|
||||
return model, tokenizer
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 다운로드 실패: {e}")
|
||||
return None, None
|
||||
|
||||
def test_nllb_model(model, tokenizer):
|
||||
print("\n🧪 NLLB 모델 기본 테스트...")
|
||||
|
||||
# Apple Silicon 최적화
|
||||
if torch.backends.mps.is_available():
|
||||
device = torch.device("mps")
|
||||
print("🚀 Apple Silicon MPS 가속 사용")
|
||||
else:
|
||||
device = torch.device("cpu")
|
||||
print("💻 CPU 모드 사용")
|
||||
|
||||
try:
|
||||
model = model.to(device)
|
||||
|
||||
# NLLB 언어 코드
|
||||
lang_codes = {
|
||||
"eng_Latn": "English",
|
||||
"jpn_Jpan": "Japanese",
|
||||
"kor_Hang": "Korean"
|
||||
}
|
||||
|
||||
def translate_test(text, src_lang, tgt_lang, desc):
|
||||
print(f"\n📝 {desc} 테스트:")
|
||||
print(f"원문: {text}")
|
||||
|
||||
tokenizer.src_lang = src_lang
|
||||
encoded = tokenizer(text, return_tensors="pt", padding=True).to(device)
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
generated_tokens = model.generate(
|
||||
**encoded,
|
||||
forced_bos_token_id=tokenizer.lang_code_to_id[tgt_lang],
|
||||
max_length=200,
|
||||
num_beams=4,
|
||||
early_stopping=True,
|
||||
do_sample=False
|
||||
)
|
||||
|
||||
translation_time = time.time() - start_time
|
||||
result = tokenizer.batch_decode(generated_tokens, skip_special_tokens=True)[0]
|
||||
|
||||
print(f"번역: {result}")
|
||||
print(f"소요 시간: {translation_time:.2f}초")
|
||||
|
||||
return result, translation_time
|
||||
|
||||
# 테스트 케이스들
|
||||
print("\n" + "="*50)
|
||||
|
||||
# 1. 영어 → 한국어
|
||||
en_text = "Artificial intelligence is transforming the way we work and live."
|
||||
translate_test(en_text, "eng_Latn", "kor_Hang", "영어 → 한국어")
|
||||
|
||||
# 2. 일본어 → 한국어
|
||||
ja_text = "人工知能は私たちの働き方と生活を変革しています。"
|
||||
translate_test(ja_text, "jpn_Jpan", "kor_Hang", "일본어 → 한국어")
|
||||
|
||||
# 3. 기술 문서 스타일
|
||||
tech_text = "Machine learning algorithms require large datasets for training."
|
||||
translate_test(tech_text, "eng_Latn", "kor_Hang", "기술 문서")
|
||||
|
||||
print("\n✅ 모든 테스트 완료!")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 테스트 실패: {e}")
|
||||
return False
|
||||
|
||||
def check_model_size():
|
||||
"""다운로드된 모델 크기 확인"""
|
||||
model_dir = Path("models")
|
||||
if model_dir.exists():
|
||||
import subprocess
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["du", "-sh", str(model_dir)],
|
||||
capture_output=True,
|
||||
text=True
|
||||
)
|
||||
if result.returncode == 0:
|
||||
size = result.stdout.strip().split()[0]
|
||||
print(f"📊 다운로드된 모델 크기: {size}")
|
||||
except:
|
||||
print("📊 모델 크기 확인 불가")
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("🚀 NLLB-200-3.3B 모델 설치")
|
||||
print("="*50)
|
||||
|
||||
# 기존 모델 확인
|
||||
model_dir = Path("models/nllb-200-3.3B")
|
||||
if model_dir.exists() and any(model_dir.iterdir()):
|
||||
print("✅ 기존 모델 발견, 로딩 시도...")
|
||||
try:
|
||||
tokenizer = AutoTokenizer.from_pretrained(str(model_dir))
|
||||
model = AutoModelForSeq2SeqLM.from_pretrained(
|
||||
str(model_dir),
|
||||
torch_dtype=torch.float16,
|
||||
low_cpu_mem_usage=True
|
||||
)
|
||||
print("✅ 기존 모델 로딩 완료")
|
||||
except:
|
||||
print("⚠️ 기존 모델 손상, 재다운로드 필요")
|
||||
model, tokenizer = download_nllb_model()
|
||||
else:
|
||||
model, tokenizer = download_nllb_model()
|
||||
|
||||
if model is not None and tokenizer is not None:
|
||||
print("\n🧪 모델 테스트 시작...")
|
||||
if test_nllb_model(model, tokenizer):
|
||||
check_model_size()
|
||||
print("\n🎉 NLLB 모델 설치 및 테스트 완료!")
|
||||
print("📝 다음 단계: KoBART 요약 모델 설치")
|
||||
else:
|
||||
print("\n❌ 모델 테스트 실패")
|
||||
else:
|
||||
print("\n❌ 모델 다운로드 실패")
|
||||
print("네트워크 연결을 확인하고 다시 시도해주세요.")
|
||||
462
src/fastapi_final.py
Normal file
462
src/fastapi_final.py
Normal file
@@ -0,0 +1,462 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Mac Mini (192.168.1.122) FastAPI + DS1525+ (192.168.1.227) 연동
|
||||
최종 운영 버전
|
||||
"""
|
||||
|
||||
from fastapi import FastAPI, File, UploadFile, HTTPException, BackgroundTasks, Request
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import HTMLResponse, FileResponse, JSONResponse
|
||||
import asyncio
|
||||
import json
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
import time
|
||||
import shutil
|
||||
from dataclasses import dataclass, asdict
|
||||
import aiofiles
|
||||
from datetime import datetime
|
||||
import subprocess
|
||||
|
||||
# 실제 네트워크 설정
|
||||
MAC_MINI_IP = "192.168.1.122"
|
||||
NAS_IP = "192.168.1.227"
|
||||
|
||||
# NAS 마운트 경로 (Finder에서 연결 시 생성되는 경로)
|
||||
NAS_MOUNT_POINT = Path("/Volumes/DS1525+")
|
||||
DOCUMENT_UPLOAD_BASE = NAS_MOUNT_POINT / "Document-upload"
|
||||
|
||||
# 세부 경로들
|
||||
NAS_ORIGINALS_PATH = DOCUMENT_UPLOAD_BASE / "originals"
|
||||
NAS_TRANSLATED_PATH = DOCUMENT_UPLOAD_BASE / "translated"
|
||||
NAS_STATIC_HOSTING_PATH = DOCUMENT_UPLOAD_BASE / "static-hosting"
|
||||
NAS_METADATA_PATH = DOCUMENT_UPLOAD_BASE / "metadata"
|
||||
|
||||
# 로컬 작업 디렉토리
|
||||
LOCAL_WORK_PATH = Path.home() / "Scripts" / "nllb-translation-system"
|
||||
|
||||
app = FastAPI(
|
||||
title="AI 번역 시스템",
|
||||
description=f"Mac Mini ({MAC_MINI_IP}) + DS1525+ ({NAS_IP}) 연동",
|
||||
version="1.0.0"
|
||||
)
|
||||
|
||||
# 정적 파일 및 템플릿
|
||||
app.mount("/static", StaticFiles(directory=str(LOCAL_WORK_PATH / "static")), name="static")
|
||||
templates = Jinja2Templates(directory=str(LOCAL_WORK_PATH / "templates"))
|
||||
|
||||
# 작업 상태 관리
|
||||
processing_jobs: Dict[str, Dict] = {}
|
||||
|
||||
def check_nas_connection():
|
||||
"""NAS 연결 상태 실시간 확인"""
|
||||
try:
|
||||
# 1. ping 테스트
|
||||
ping_result = subprocess.run(
|
||||
["ping", "-c", "1", "-W", "3000", NAS_IP],
|
||||
capture_output=True,
|
||||
timeout=5
|
||||
)
|
||||
|
||||
if ping_result.returncode != 0:
|
||||
return {"status": "offline", "error": f"NAS {NAS_IP} ping 실패"}
|
||||
|
||||
# 2. 마운트 상태 확인
|
||||
if not NAS_MOUNT_POINT.exists():
|
||||
return {"status": "not_mounted", "error": "마운트 포인트 없음"}
|
||||
|
||||
# 3. 실제 마운트 확인
|
||||
mount_result = subprocess.run(
|
||||
["mount"],
|
||||
capture_output=True,
|
||||
text=True
|
||||
)
|
||||
|
||||
if str(NAS_MOUNT_POINT) not in mount_result.stdout:
|
||||
return {"status": "not_mounted", "error": "NAS가 마운트되지 않음"}
|
||||
|
||||
# 4. Document-upload 폴더 확인
|
||||
if not DOCUMENT_UPLOAD_BASE.exists():
|
||||
return {"status": "folder_missing", "error": "Document-upload 폴더 없음"}
|
||||
|
||||
return {"status": "connected", "mount_point": str(NAS_MOUNT_POINT)}
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
return {"status": "timeout", "error": "연결 타임아웃"}
|
||||
except Exception as e:
|
||||
return {"status": "error", "error": str(e)}
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup_event():
|
||||
"""서버 시작시 전체 시스템 상태 확인"""
|
||||
print(f"🚀 Mac Mini AI 번역 서버 시작")
|
||||
print(f"📍 Mac Mini IP: {MAC_MINI_IP}")
|
||||
print(f"📍 NAS IP: {NAS_IP}")
|
||||
print("-" * 50)
|
||||
|
||||
# NAS 연결 상태 확인
|
||||
nas_status = check_nas_connection()
|
||||
|
||||
if nas_status["status"] == "connected":
|
||||
print(f"✅ NAS 연결 정상: {nas_status['mount_point']}")
|
||||
|
||||
# 필요한 폴더 구조 자동 생성
|
||||
try:
|
||||
current_month = datetime.now().strftime("%Y-%m")
|
||||
|
||||
folders_to_create = [
|
||||
NAS_ORIGINALS_PATH / current_month / "pdfs",
|
||||
NAS_ORIGINALS_PATH / current_month / "docs",
|
||||
NAS_ORIGINALS_PATH / current_month / "txts",
|
||||
NAS_TRANSLATED_PATH / current_month / "english-to-korean",
|
||||
NAS_TRANSLATED_PATH / current_month / "japanese-to-korean",
|
||||
NAS_TRANSLATED_PATH / current_month / "korean-only",
|
||||
NAS_STATIC_HOSTING_PATH / "docs",
|
||||
NAS_STATIC_HOSTING_PATH / "assets",
|
||||
NAS_METADATA_PATH / "processing-logs"
|
||||
]
|
||||
|
||||
for folder in folders_to_create:
|
||||
folder.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
print(f"✅ 폴더 구조 확인/생성 완료")
|
||||
|
||||
except Exception as e:
|
||||
print(f"⚠️ 폴더 생성 실패: {e}")
|
||||
|
||||
else:
|
||||
print(f"❌ NAS 연결 실패: {nas_status['error']}")
|
||||
print("해결 방법:")
|
||||
print("1. NAS 전원 및 네트워크 상태 확인")
|
||||
print("2. Finder에서 DS1525+ 수동 연결:")
|
||||
print(f" - 이동 → 서버에 연결 → smb://{NAS_IP}")
|
||||
print("3. 연결 후 서버 재시작")
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
async def index(request: Request):
|
||||
"""메인 페이지"""
|
||||
nas_status = check_nas_connection()
|
||||
return templates.TemplateResponse("index.html", {
|
||||
"request": request,
|
||||
"nas_status": nas_status
|
||||
})
|
||||
|
||||
@app.get("/system-status")
|
||||
async def system_status():
|
||||
"""전체 시스템 상태 확인"""
|
||||
|
||||
# NAS 상태
|
||||
nas_status = check_nas_connection()
|
||||
|
||||
# Mac Mini 상태
|
||||
mac_status = {
|
||||
"ip": MAC_MINI_IP,
|
||||
"hostname": subprocess.getoutput("hostname"),
|
||||
"uptime": subprocess.getoutput("uptime"),
|
||||
"disk_usage": {}
|
||||
}
|
||||
|
||||
# 디스크 사용량 확인
|
||||
try:
|
||||
disk_result = subprocess.run(
|
||||
["df", "-h", str(Path.home())],
|
||||
capture_output=True,
|
||||
text=True
|
||||
)
|
||||
if disk_result.returncode == 0:
|
||||
lines = disk_result.stdout.strip().split('\n')
|
||||
if len(lines) > 1:
|
||||
parts = lines[1].split()
|
||||
mac_status["disk_usage"] = {
|
||||
"total": parts[1],
|
||||
"used": parts[2],
|
||||
"available": parts[3],
|
||||
"percentage": parts[4]
|
||||
}
|
||||
except:
|
||||
pass
|
||||
|
||||
# AI 모델 상태 (간단 체크)
|
||||
ai_status = {
|
||||
"models_available": False,
|
||||
"memory_usage": "Unknown"
|
||||
}
|
||||
|
||||
try:
|
||||
# NLLB 모델 폴더 확인
|
||||
model_path = LOCAL_WORK_PATH / "models"
|
||||
if model_path.exists():
|
||||
ai_status["models_available"] = True
|
||||
except:
|
||||
pass
|
||||
|
||||
# 작업 통계
|
||||
job_stats = {
|
||||
"total_jobs": len(processing_jobs),
|
||||
"completed": len([j for j in processing_jobs.values() if j["status"] == "completed"]),
|
||||
"processing": len([j for j in processing_jobs.values() if j["status"] == "processing"]),
|
||||
"failed": len([j for j in processing_jobs.values() if j["status"] == "error"])
|
||||
}
|
||||
|
||||
return {
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"nas": nas_status,
|
||||
"mac_mini": mac_status,
|
||||
"ai_models": ai_status,
|
||||
"jobs": job_stats
|
||||
}
|
||||
|
||||
@app.post("/upload")
|
||||
async def upload_file(background_tasks: BackgroundTasks, file: UploadFile = File(...)):
|
||||
"""파일 업로드 (NAS 연결 상태 확인 포함)"""
|
||||
|
||||
# NAS 연결 상태 먼저 확인
|
||||
nas_status = check_nas_connection()
|
||||
if nas_status["status"] != "connected":
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail=f"NAS 연결 실패: {nas_status['error']}. Finder에서 DS1525+를 연결해주세요."
|
||||
)
|
||||
|
||||
# 파일 검증
|
||||
if not file.filename:
|
||||
raise HTTPException(status_code=400, detail="파일이 선택되지 않았습니다.")
|
||||
|
||||
allowed_extensions = {'.pdf', '.txt', '.docx', '.doc'}
|
||||
file_ext = Path(file.filename).suffix.lower()
|
||||
|
||||
if file_ext not in allowed_extensions:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"지원되지 않는 파일 형식입니다. 지원 형식: {', '.join(allowed_extensions)}"
|
||||
)
|
||||
|
||||
# 파일 크기 확인 (100MB 제한)
|
||||
content = await file.read()
|
||||
if len(content) > 100 * 1024 * 1024:
|
||||
raise HTTPException(status_code=413, detail="파일 크기는 100MB를 초과할 수 없습니다.")
|
||||
|
||||
# 고유 작업 ID 생성
|
||||
job_id = str(uuid.uuid4())
|
||||
|
||||
# NAS에 체계적으로 저장
|
||||
current_month = datetime.now().strftime("%Y-%m")
|
||||
file_type_folder = {
|
||||
'.pdf': 'pdfs',
|
||||
'.doc': 'docs',
|
||||
'.docx': 'docs',
|
||||
'.txt': 'txts'
|
||||
}.get(file_ext, 'others')
|
||||
|
||||
nas_original_dir = NAS_ORIGINALS_PATH / current_month / file_type_folder
|
||||
nas_original_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 안전한 파일명 생성
|
||||
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||
safe_filename = f"{timestamp}_{job_id[:8]}_{file.filename}".replace(" ", "_")
|
||||
nas_file_path = nas_original_dir / safe_filename
|
||||
|
||||
# 파일 저장
|
||||
try:
|
||||
async with aiofiles.open(nas_file_path, 'wb') as f:
|
||||
await f.write(content)
|
||||
|
||||
print(f"📁 파일 저장 완료: {nas_file_path}")
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"파일 저장 실패: {str(e)}")
|
||||
|
||||
# 작업 상태 초기화
|
||||
job = {
|
||||
"id": job_id,
|
||||
"filename": file.filename,
|
||||
"file_type": file_ext,
|
||||
"status": "uploaded",
|
||||
"progress": 0,
|
||||
"message": f"파일 업로드 완료, NAS에 저장됨 ({nas_file_path.name})",
|
||||
"nas_original_path": str(nas_file_path),
|
||||
"created_at": time.time()
|
||||
}
|
||||
|
||||
processing_jobs[job_id] = job
|
||||
|
||||
# 백그라운드에서 처리 시작
|
||||
background_tasks.add_task(process_document_with_nas_final, job_id, nas_file_path)
|
||||
|
||||
return {
|
||||
"job_id": job_id,
|
||||
"message": "파일 업로드 완료, AI 처리를 시작합니다.",
|
||||
"nas_path": str(nas_file_path)
|
||||
}
|
||||
|
||||
async def process_document_with_nas_final(job_id: str, nas_file_path: Path):
|
||||
"""최종 문서 처리 파이프라인"""
|
||||
|
||||
try:
|
||||
processing_jobs[job_id].update({
|
||||
"status": "processing",
|
||||
"progress": 10,
|
||||
"message": "AI 모델 로딩 및 텍스트 추출 중..."
|
||||
})
|
||||
|
||||
# 1. 통합 번역 시스템 로드
|
||||
import sys
|
||||
sys.path.append(str(LOCAL_WORK_PATH / "src"))
|
||||
|
||||
from integrated_translation_system import IntegratedTranslationSystem
|
||||
|
||||
system = IntegratedTranslationSystem()
|
||||
if not system.load_models():
|
||||
raise Exception("AI 모델 로드 실패")
|
||||
|
||||
processing_jobs[job_id].update({
|
||||
"progress": 30,
|
||||
"message": "문서 분석 및 언어 감지 중..."
|
||||
})
|
||||
|
||||
# 2. 문서 처리
|
||||
result = system.process_document(str(nas_file_path))
|
||||
detected_lang = result.metadata["detected_language"]
|
||||
|
||||
processing_jobs[job_id].update({
|
||||
"progress": 60,
|
||||
"message": f"언어 감지: {detected_lang}, 다국어 HTML 생성 중..."
|
||||
})
|
||||
|
||||
# 3. HTML 생성
|
||||
from html_generator import MultilingualHTMLGenerator
|
||||
|
||||
generator = MultilingualHTMLGenerator()
|
||||
|
||||
# 언어별 콘텐츠 준비
|
||||
contents = {}
|
||||
translation_folder = ""
|
||||
|
||||
if detected_lang == "korean":
|
||||
contents["korean"] = result.original_text
|
||||
translation_folder = "korean-only"
|
||||
elif detected_lang == "english":
|
||||
contents["english"] = result.original_text
|
||||
contents["korean"] = result.translated_text
|
||||
translation_folder = "english-to-korean"
|
||||
elif detected_lang == "japanese":
|
||||
contents["japanese"] = result.original_text
|
||||
contents["korean"] = result.translated_text
|
||||
translation_folder = "japanese-to-korean"
|
||||
else:
|
||||
contents[detected_lang] = result.original_text
|
||||
contents["korean"] = result.translated_text
|
||||
translation_folder = "unknown-language"
|
||||
|
||||
# NAS 저장 경로 결정
|
||||
current_month = datetime.now().strftime("%Y-%m")
|
||||
nas_translated_dir = NAS_TRANSLATED_PATH / current_month / translation_folder
|
||||
nas_translated_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 원본 파일명에서 HTML 파일명 생성
|
||||
base_name = Path(nas_file_path.stem).stem
|
||||
# 타임스탬프와 job_id 제거
|
||||
if '_' in base_name:
|
||||
parts = base_name.split('_')[2:] # 처음 2개 부분 제거
|
||||
if parts:
|
||||
base_name = '_'.join(parts)
|
||||
|
||||
html_filename = f"{base_name}.html"
|
||||
nas_html_path = nas_translated_dir / html_filename
|
||||
|
||||
# 중복 파일명 처리
|
||||
counter = 1
|
||||
while nas_html_path.exists():
|
||||
html_filename = f"{base_name}_{counter}.html"
|
||||
nas_html_path = nas_translated_dir / html_filename
|
||||
counter += 1
|
||||
|
||||
# HTML 생성
|
||||
generator.generate_multilingual_html(
|
||||
title=base_name,
|
||||
contents=contents,
|
||||
summary=result.summary,
|
||||
metadata={
|
||||
**result.metadata,
|
||||
"nas_original_path": str(nas_file_path),
|
||||
"translation_type": translation_folder,
|
||||
"nas_ip": NAS_IP,
|
||||
"mac_mini_ip": MAC_MINI_IP
|
||||
},
|
||||
output_path=str(nas_html_path)
|
||||
)
|
||||
|
||||
processing_jobs[job_id].update({
|
||||
"progress": 85,
|
||||
"message": "정적 호스팅 준비 및 인덱스 업데이트 중..."
|
||||
})
|
||||
|
||||
# 4. 정적 호스팅 폴더에 복사
|
||||
static_hosting_file = NAS_STATIC_HOSTING_PATH / "docs" / html_filename
|
||||
shutil.copy2(nas_html_path, static_hosting_file)
|
||||
|
||||
# 5. 메타데이터 저장
|
||||
await save_processing_metadata(job_id, nas_file_path, nas_html_path, result.metadata)
|
||||
|
||||
processing_jobs[job_id].update({
|
||||
"progress": 100,
|
||||
"status": "completed",
|
||||
"message": f"처리 완료! ({translation_folder})",
|
||||
"nas_translated_path": str(nas_html_path),
|
||||
"static_hosting_path": str(static_hosting_file),
|
||||
"detected_language": detected_lang,
|
||||
"translation_type": translation_folder
|
||||
})
|
||||
|
||||
print(f"✅ 작업 완료: {job_id} - {html_filename}")
|
||||
|
||||
except Exception as e:
|
||||
processing_jobs[job_id].update({
|
||||
"status": "error",
|
||||
"progress": 0,
|
||||
"message": f"처리 실패: {str(e)}"
|
||||
})
|
||||
|
||||
print(f"❌ 작업 실패: {job_id} - {str(e)}")
|
||||
|
||||
async def save_processing_metadata(job_id: str, original_path: Path, html_path: Path, metadata: Dict):
|
||||
"""처리 메타데이터 NAS에 저장"""
|
||||
|
||||
log_data = {
|
||||
"job_id": job_id,
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"original_file": str(original_path),
|
||||
"html_file": str(html_path),
|
||||
"metadata": metadata,
|
||||
"nas_ip": NAS_IP,
|
||||
"mac_mini_ip": MAC_MINI_IP
|
||||
}
|
||||
|
||||
# 월별 로그 파일에 추가
|
||||
current_month = datetime.now().strftime("%Y-%m")
|
||||
log_file = NAS_METADATA_PATH / "processing-logs" / f"{current_month}.jsonl"
|
||||
|
||||
try:
|
||||
async with aiofiles.open(log_file, 'a', encoding='utf-8') as f:
|
||||
await f.write(json.dumps(log_data, ensure_ascii=False) + '\n')
|
||||
except Exception as e:
|
||||
print(f"메타데이터 저장 실패: {e}")
|
||||
|
||||
@app.get("/status/{job_id}")
|
||||
async def get_job_status(job_id: str):
|
||||
"""작업 상태 조회"""
|
||||
if job_id not in processing_jobs:
|
||||
raise HTTPException(status_code=404, detail="작업을 찾을 수 없습니다.")
|
||||
|
||||
return processing_jobs[job_id]
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
|
||||
print(f"🚀 Mac Mini AI 번역 서버")
|
||||
print(f"📡 서버 주소: http://{MAC_MINI_IP}:8080")
|
||||
print(f"📁 NAS 주소: {NAS_IP}")
|
||||
|
||||
uvicorn.run(app, host="0.0.0.0", port=8080)
|
||||
575
src/fastapi_media_mount.py
Normal file
575
src/fastapi_media_mount.py
Normal file
@@ -0,0 +1,575 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
/Volumes/Media 마운트 기반 FastAPI
|
||||
최종 운영 버전
|
||||
"""
|
||||
|
||||
from fastapi import FastAPI, File, UploadFile, HTTPException, BackgroundTasks, Request
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import HTMLResponse, FileResponse, JSONResponse
|
||||
import asyncio
|
||||
import json
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
import time
|
||||
import shutil
|
||||
from dataclasses import dataclass, asdict
|
||||
import aiofiles
|
||||
from datetime import datetime
|
||||
import subprocess
|
||||
|
||||
# 실제 네트워크 설정
|
||||
MAC_MINI_IP = "192.168.1.122"
|
||||
NAS_IP = "192.168.1.227"
|
||||
|
||||
# 기존 연결된 Media 마운트 사용
|
||||
NAS_MOUNT_POINT = Path("/Volumes/Media")
|
||||
DOCUMENT_UPLOAD_BASE = NAS_MOUNT_POINT / "Document-upload"
|
||||
|
||||
# 세부 경로들
|
||||
NAS_ORIGINALS_PATH = DOCUMENT_UPLOAD_BASE / "originals"
|
||||
NAS_TRANSLATED_PATH = DOCUMENT_UPLOAD_BASE / "translated"
|
||||
NAS_STATIC_HOSTING_PATH = DOCUMENT_UPLOAD_BASE / "static-hosting"
|
||||
NAS_METADATA_PATH = DOCUMENT_UPLOAD_BASE / "metadata"
|
||||
|
||||
# 로컬 작업 디렉토리
|
||||
LOCAL_WORK_PATH = Path.home() / "Scripts" / "nllb-translation-system"
|
||||
|
||||
app = FastAPI(
|
||||
title="AI 번역 시스템",
|
||||
description=f"Mac Mini ({MAC_MINI_IP}) + Media Mount 연동",
|
||||
version="1.0.0"
|
||||
)
|
||||
|
||||
# 정적 파일 및 템플릿 (있는 경우에만)
|
||||
if (LOCAL_WORK_PATH / "static").exists():
|
||||
app.mount("/static", StaticFiles(directory=str(LOCAL_WORK_PATH / "static")), name="static")
|
||||
|
||||
if (LOCAL_WORK_PATH / "templates").exists():
|
||||
templates = Jinja2Templates(directory=str(LOCAL_WORK_PATH / "templates"))
|
||||
else:
|
||||
templates = None
|
||||
|
||||
# 작업 상태 관리
|
||||
processing_jobs: Dict[str, Dict] = {}
|
||||
|
||||
def check_nas_connection():
|
||||
"""NAS 연결 상태 확인"""
|
||||
try:
|
||||
# 1. Media 마운트 확인
|
||||
if not NAS_MOUNT_POINT.exists():
|
||||
return {"status": "not_mounted", "error": "Media 마운트 포인트 없음"}
|
||||
|
||||
# 2. 쓰기 권한 확인
|
||||
try:
|
||||
test_file = NAS_MOUNT_POINT / ".test_write"
|
||||
test_file.touch()
|
||||
test_file.unlink()
|
||||
except:
|
||||
return {"status": "read_only", "error": "Media 마운트 읽기 전용"}
|
||||
|
||||
# 3. Document-upload 폴더 확인
|
||||
if not DOCUMENT_UPLOAD_BASE.exists():
|
||||
return {"status": "folder_missing", "error": "Document-upload 폴더 없음"}
|
||||
|
||||
return {"status": "connected", "mount_point": str(NAS_MOUNT_POINT)}
|
||||
|
||||
except Exception as e:
|
||||
return {"status": "error", "error": str(e)}
|
||||
|
||||
def ensure_nas_directories():
|
||||
"""NAS 디렉토리 구조 생성"""
|
||||
try:
|
||||
# Document-upload 기본 구조
|
||||
DOCUMENT_UPLOAD_BASE.mkdir(exist_ok=True)
|
||||
|
||||
current_month = datetime.now().strftime("%Y-%m")
|
||||
|
||||
folders_to_create = [
|
||||
NAS_ORIGINALS_PATH / current_month / "pdfs",
|
||||
NAS_ORIGINALS_PATH / current_month / "docs",
|
||||
NAS_ORIGINALS_PATH / current_month / "txts",
|
||||
NAS_TRANSLATED_PATH / current_month / "english-to-korean",
|
||||
NAS_TRANSLATED_PATH / current_month / "japanese-to-korean",
|
||||
NAS_TRANSLATED_PATH / current_month / "korean-only",
|
||||
NAS_STATIC_HOSTING_PATH / "docs",
|
||||
NAS_STATIC_HOSTING_PATH / "assets",
|
||||
NAS_STATIC_HOSTING_PATH / "index",
|
||||
NAS_METADATA_PATH / "processing-logs"
|
||||
]
|
||||
|
||||
for folder in folders_to_create:
|
||||
folder.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# README 파일 생성
|
||||
readme_content = f"""# AI 번역 시스템 문서 저장소
|
||||
|
||||
자동 생성 시간: {datetime.now().isoformat()}
|
||||
Mac Mini IP: {MAC_MINI_IP}
|
||||
NAS IP: {NAS_IP}
|
||||
|
||||
## 폴더 구조
|
||||
|
||||
- originals/: 업로드된 원본 파일들
|
||||
- translated/: 번역된 HTML 파일들
|
||||
- static-hosting/: 웹 호스팅용 파일들
|
||||
- metadata/: 처리 로그 및 메타데이터
|
||||
|
||||
## 자동 관리
|
||||
|
||||
이 폴더는 AI 번역 시스템에서 자동으로 관리됩니다.
|
||||
수동으로 파일을 수정하지 마세요.
|
||||
"""
|
||||
|
||||
readme_path = DOCUMENT_UPLOAD_BASE / "README.md"
|
||||
with open(readme_path, 'w', encoding='utf-8') as f:
|
||||
f.write(readme_content)
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 디렉토리 생성 실패: {e}")
|
||||
return False
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup_event():
|
||||
"""서버 시작시 전체 시스템 상태 확인"""
|
||||
print(f"🚀 Mac Mini AI 번역 서버 시작")
|
||||
print(f"📍 Mac Mini IP: {MAC_MINI_IP}")
|
||||
print(f"📍 NAS Mount: {NAS_MOUNT_POINT}")
|
||||
print("-" * 50)
|
||||
|
||||
# NAS 연결 상태 확인
|
||||
nas_status = check_nas_connection()
|
||||
|
||||
if nas_status["status"] == "connected":
|
||||
print(f"✅ NAS 연결 정상: {nas_status['mount_point']}")
|
||||
|
||||
if ensure_nas_directories():
|
||||
print(f"✅ 폴더 구조 확인/생성 완료")
|
||||
print(f"📁 문서 저장 경로: {DOCUMENT_UPLOAD_BASE}")
|
||||
else:
|
||||
print(f"⚠️ 폴더 생성 실패")
|
||||
else:
|
||||
print(f"❌ NAS 연결 실패: {nas_status['error']}")
|
||||
|
||||
@app.get("/")
|
||||
async def index(request: Request = None):
|
||||
"""메인 페이지"""
|
||||
nas_status = check_nas_connection()
|
||||
|
||||
if templates:
|
||||
return templates.TemplateResponse("index.html", {
|
||||
"request": request,
|
||||
"nas_status": nas_status
|
||||
})
|
||||
else:
|
||||
# 템플릿이 없으면 간단한 HTML 반환
|
||||
html_content = f"""
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>AI 번역 시스템</title>
|
||||
<style>
|
||||
body {{ font-family: -apple-system, BlinkMacSystemFont, sans-serif; margin: 40px; }}
|
||||
.container {{ max-width: 800px; margin: 0 auto; }}
|
||||
.status {{ padding: 20px; border-radius: 8px; margin: 20px 0; }}
|
||||
.status-good {{ background: #d1fae5; color: #065f46; }}
|
||||
.status-bad {{ background: #fee2e2; color: #dc2626; }}
|
||||
.upload-area {{ border: 2px dashed #ccc; padding: 40px; text-align: center; border-radius: 8px; }}
|
||||
input[type="file"] {{ margin: 20px 0; }}
|
||||
button {{ background: #2563eb; color: white; padding: 12px 24px; border: none; border-radius: 6px; cursor: pointer; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>🤖 AI 번역 시스템</h1>
|
||||
|
||||
<div class="status {'status-good' if nas_status['status'] == 'connected' else 'status-bad'}">
|
||||
{'✅ NAS 연결됨: ' + nas_status.get('mount_point', '') if nas_status['status'] == 'connected' else '❌ NAS 연결 실패: ' + nas_status.get('error', '')}
|
||||
</div>
|
||||
|
||||
<div class="upload-area">
|
||||
<h3>파일 업로드</h3>
|
||||
<form id="uploadForm" enctype="multipart/form-data">
|
||||
<input type="file" id="fileInput" accept=".pdf,.txt,.docx,.doc" />
|
||||
<br>
|
||||
<button type="submit" {'disabled' if nas_status['status'] != 'connected' else ''}>업로드</button>
|
||||
</form>
|
||||
<div id="status"></div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3>API 엔드포인트</h3>
|
||||
<ul>
|
||||
<li><a href="/system-status">시스템 상태</a></li>
|
||||
<li><a href="/nas-info">NAS 정보</a></li>
|
||||
<li><a href="/docs">API 문서</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.getElementById('uploadForm').onsubmit = async function(e) {{
|
||||
e.preventDefault();
|
||||
const fileInput = document.getElementById('fileInput');
|
||||
const statusDiv = document.getElementById('status');
|
||||
|
||||
if (!fileInput.files[0]) {{
|
||||
statusDiv.innerHTML = '파일을 선택해주세요.';
|
||||
return;
|
||||
}}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', fileInput.files[0]);
|
||||
|
||||
statusDiv.innerHTML = '업로드 중...';
|
||||
|
||||
try {{
|
||||
const response = await fetch('/upload', {{
|
||||
method: 'POST',
|
||||
body: formData
|
||||
}});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok) {{
|
||||
statusDiv.innerHTML = `✅ 업로드 성공! Job ID: ${{result.job_id}}`;
|
||||
|
||||
// 진행 상황 모니터링
|
||||
monitorJob(result.job_id);
|
||||
}} else {{
|
||||
statusDiv.innerHTML = `❌ 업로드 실패: ${{result.detail}}`;
|
||||
}}
|
||||
}} catch (error) {{
|
||||
statusDiv.innerHTML = `❌ 오류: ${{error.message}}`;
|
||||
}}
|
||||
}};
|
||||
|
||||
async function monitorJob(jobId) {{
|
||||
const statusDiv = document.getElementById('status');
|
||||
|
||||
while (true) {{
|
||||
try {{
|
||||
const response = await fetch(`/status/${{jobId}}`);
|
||||
const status = await response.json();
|
||||
|
||||
statusDiv.innerHTML = `진행률: ${{status.progress}}% - ${{status.message}}`;
|
||||
|
||||
if (status.status === 'completed') {{
|
||||
statusDiv.innerHTML += `<br><a href="/download/${{jobId}}">📥 다운로드</a>`;
|
||||
break;
|
||||
}} else if (status.status === 'error') {{
|
||||
statusDiv.innerHTML = `❌ 처리 실패: ${{status.message}}`;
|
||||
break;
|
||||
}}
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
}} catch (error) {{
|
||||
console.error('상태 확인 오류:', error);
|
||||
await new Promise(resolve => setTimeout(resolve, 5000));
|
||||
}}
|
||||
}}
|
||||
}}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
return HTMLResponse(content=html_content)
|
||||
|
||||
@app.get("/system-status")
|
||||
async def system_status():
|
||||
"""전체 시스템 상태"""
|
||||
nas_status = check_nas_connection()
|
||||
|
||||
# 파일 통계
|
||||
file_stats = {"originals": 0, "translated": 0, "total_size": 0}
|
||||
|
||||
try:
|
||||
if NAS_ORIGINALS_PATH.exists():
|
||||
original_files = list(NAS_ORIGINALS_PATH.rglob("*.*"))
|
||||
file_stats["originals"] = len([f for f in original_files if f.is_file()])
|
||||
|
||||
if NAS_TRANSLATED_PATH.exists():
|
||||
html_files = list(NAS_TRANSLATED_PATH.rglob("*.html"))
|
||||
file_stats["translated"] = len(html_files)
|
||||
file_stats["total_size"] = sum(f.stat().st_size for f in html_files if f.exists())
|
||||
except:
|
||||
pass
|
||||
|
||||
return {
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"mac_mini_ip": MAC_MINI_IP,
|
||||
"nas_mount": str(NAS_MOUNT_POINT),
|
||||
"nas_status": nas_status,
|
||||
"file_stats": file_stats,
|
||||
"active_jobs": len(processing_jobs),
|
||||
"document_upload_path": str(DOCUMENT_UPLOAD_BASE)
|
||||
}
|
||||
|
||||
@app.post("/upload")
|
||||
async def upload_file(background_tasks: BackgroundTasks, file: UploadFile = File(...)):
|
||||
"""파일 업로드"""
|
||||
|
||||
# NAS 연결 상태 먼저 확인
|
||||
nas_status = check_nas_connection()
|
||||
if nas_status["status"] != "connected":
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail=f"NAS 연결 실패: {nas_status['error']}"
|
||||
)
|
||||
|
||||
# 파일 검증
|
||||
if not file.filename:
|
||||
raise HTTPException(status_code=400, detail="파일이 선택되지 않았습니다.")
|
||||
|
||||
allowed_extensions = {'.pdf', '.txt', '.docx', '.doc'}
|
||||
file_ext = Path(file.filename).suffix.lower()
|
||||
|
||||
if file_ext not in allowed_extensions:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"지원되지 않는 파일 형식입니다. 지원: {', '.join(allowed_extensions)}"
|
||||
)
|
||||
|
||||
# 파일 크기 확인 (100MB 제한)
|
||||
content = await file.read()
|
||||
if len(content) > 100 * 1024 * 1024:
|
||||
raise HTTPException(status_code=413, detail="파일 크기는 100MB를 초과할 수 없습니다.")
|
||||
|
||||
# 고유 작업 ID 생성
|
||||
job_id = str(uuid.uuid4())
|
||||
|
||||
# NAS에 저장
|
||||
current_month = datetime.now().strftime("%Y-%m")
|
||||
file_type_folder = {
|
||||
'.pdf': 'pdfs',
|
||||
'.doc': 'docs',
|
||||
'.docx': 'docs',
|
||||
'.txt': 'txts'
|
||||
}.get(file_ext, 'others')
|
||||
|
||||
nas_original_dir = NAS_ORIGINALS_PATH / current_month / file_type_folder
|
||||
nas_original_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 안전한 파일명 생성
|
||||
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||
safe_filename = f"{timestamp}_{job_id[:8]}_{file.filename}".replace(" ", "_")
|
||||
nas_file_path = nas_original_dir / safe_filename
|
||||
|
||||
# 파일 저장
|
||||
try:
|
||||
async with aiofiles.open(nas_file_path, 'wb') as f:
|
||||
await f.write(content)
|
||||
|
||||
print(f"📁 파일 저장: {nas_file_path}")
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"파일 저장 실패: {str(e)}")
|
||||
|
||||
# 작업 상태 초기화
|
||||
job = {
|
||||
"id": job_id,
|
||||
"filename": file.filename,
|
||||
"file_type": file_ext,
|
||||
"status": "uploaded",
|
||||
"progress": 0,
|
||||
"message": f"파일 저장 완료: {safe_filename}",
|
||||
"nas_original_path": str(nas_file_path),
|
||||
"created_at": time.time()
|
||||
}
|
||||
|
||||
processing_jobs[job_id] = job
|
||||
|
||||
# 백그라운드 처리 시작
|
||||
background_tasks.add_task(process_document_simple, job_id, nas_file_path)
|
||||
|
||||
return {
|
||||
"job_id": job_id,
|
||||
"message": "파일 업로드 완료, 처리를 시작합니다.",
|
||||
"nas_path": str(nas_file_path)
|
||||
}
|
||||
|
||||
async def process_document_simple(job_id: str, nas_file_path: Path):
|
||||
"""간단한 문서 처리 (AI 모델 연동 전 테스트용)"""
|
||||
|
||||
try:
|
||||
processing_jobs[job_id].update({
|
||||
"status": "processing",
|
||||
"progress": 30,
|
||||
"message": "텍스트 추출 중..."
|
||||
})
|
||||
|
||||
await asyncio.sleep(2) # 시뮬레이션
|
||||
|
||||
processing_jobs[job_id].update({
|
||||
"progress": 60,
|
||||
"message": "언어 감지 및 번역 중..."
|
||||
})
|
||||
|
||||
await asyncio.sleep(3) # 시뮬레이션
|
||||
|
||||
# 간단한 HTML 생성 (테스트용)
|
||||
current_month = datetime.now().strftime("%Y-%m")
|
||||
nas_translated_dir = NAS_TRANSLATED_PATH / current_month / "korean-only"
|
||||
nas_translated_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
base_name = Path(nas_file_path.stem).stem
|
||||
if '_' in base_name:
|
||||
parts = base_name.split('_')[2:] # 타임스탬프와 job_id 제거
|
||||
if parts:
|
||||
base_name = '_'.join(parts)
|
||||
|
||||
html_filename = f"{base_name}.html"
|
||||
nas_html_path = nas_translated_dir / html_filename
|
||||
|
||||
# 테스트용 HTML 생성
|
||||
html_content = f"""<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>{base_name}</title>
|
||||
<style>
|
||||
body {{ font-family: -apple-system, BlinkMacSystemFont, sans-serif; margin: 40px; line-height: 1.6; }}
|
||||
.header {{ background: #f8fafc; padding: 20px; border-radius: 8px; margin-bottom: 30px; }}
|
||||
.content {{ max-width: 800px; margin: 0 auto; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="content">
|
||||
<div class="header">
|
||||
<h1>📄 {base_name}</h1>
|
||||
<p>처리 시간: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}</p>
|
||||
<p>원본 파일: {nas_file_path.name}</p>
|
||||
<p>Job ID: {job_id}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2>테스트 문서</h2>
|
||||
<p>이것은 AI 번역 시스템의 테스트 출력입니다.</p>
|
||||
<p>실제 AI 모델이 연동되면 이 부분에 번역된 내용이 표시됩니다.</p>
|
||||
|
||||
<h3>시스템 정보</h3>
|
||||
<ul>
|
||||
<li>Mac Mini IP: {MAC_MINI_IP}</li>
|
||||
<li>NAS Mount: {NAS_MOUNT_POINT}</li>
|
||||
<li>저장 경로: {nas_html_path}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
# HTML 파일 저장
|
||||
async with aiofiles.open(nas_html_path, 'w', encoding='utf-8') as f:
|
||||
await f.write(html_content)
|
||||
|
||||
processing_jobs[job_id].update({
|
||||
"progress": 100,
|
||||
"status": "completed",
|
||||
"message": "처리 완료!",
|
||||
"nas_translated_path": str(nas_html_path)
|
||||
})
|
||||
|
||||
print(f"✅ 테스트 처리 완료: {job_id} - {html_filename}")
|
||||
|
||||
except Exception as e:
|
||||
processing_jobs[job_id].update({
|
||||
"status": "error",
|
||||
"progress": 0,
|
||||
"message": f"처리 실패: {str(e)}"
|
||||
})
|
||||
|
||||
print(f"❌ 처리 실패: {job_id} - {str(e)}")
|
||||
|
||||
@app.get("/status/{job_id}")
|
||||
async def get_job_status(job_id: str):
|
||||
"""작업 상태 조회"""
|
||||
if job_id not in processing_jobs:
|
||||
raise HTTPException(status_code=404, detail="작업을 찾을 수 없습니다.")
|
||||
|
||||
return processing_jobs[job_id]
|
||||
|
||||
@app.get("/download/{job_id}")
|
||||
async def download_result(job_id: str):
|
||||
"""결과 파일 다운로드"""
|
||||
if job_id not in processing_jobs:
|
||||
raise HTTPException(status_code=404, detail="작업을 찾을 수 없습니다.")
|
||||
|
||||
job = processing_jobs[job_id]
|
||||
|
||||
if job["status"] != "completed" or not job.get("nas_translated_path"):
|
||||
raise HTTPException(status_code=400, detail="처리가 완료되지 않았습니다.")
|
||||
|
||||
result_path = Path(job["nas_translated_path"])
|
||||
if not result_path.exists():
|
||||
raise HTTPException(status_code=404, detail="결과 파일을 찾을 수 없습니다.")
|
||||
|
||||
return FileResponse(
|
||||
path=result_path,
|
||||
filename=f"{Path(job['filename']).stem}.html",
|
||||
media_type="text/html"
|
||||
)
|
||||
|
||||
@app.get("/nas-info")
|
||||
async def nas_info():
|
||||
"""NAS 정보 및 통계"""
|
||||
|
||||
nas_status = check_nas_connection()
|
||||
|
||||
info = {
|
||||
"nas_status": nas_status,
|
||||
"mount_point": str(NAS_MOUNT_POINT),
|
||||
"document_upload_base": str(DOCUMENT_UPLOAD_BASE),
|
||||
"folders": {},
|
||||
"statistics": {
|
||||
"total_jobs": len(processing_jobs),
|
||||
"completed_jobs": len([j for j in processing_jobs.values() if j["status"] == "completed"]),
|
||||
"failed_jobs": len([j for j in processing_jobs.values() if j["status"] == "error"])
|
||||
}
|
||||
}
|
||||
|
||||
# 폴더 정보 수집
|
||||
try:
|
||||
for folder_name, folder_path in [
|
||||
("originals", NAS_ORIGINALS_PATH),
|
||||
("translated", NAS_TRANSLATED_PATH),
|
||||
("static-hosting", NAS_STATIC_HOSTING_PATH),
|
||||
("metadata", NAS_METADATA_PATH)
|
||||
]:
|
||||
if folder_path.exists():
|
||||
files = list(folder_path.rglob("*.*"))
|
||||
file_count = len([f for f in files if f.is_file()])
|
||||
total_size = sum(f.stat().st_size for f in files if f.is_file())
|
||||
|
||||
info["folders"][folder_name] = {
|
||||
"exists": True,
|
||||
"file_count": file_count,
|
||||
"total_size_mb": round(total_size / (1024 * 1024), 2)
|
||||
}
|
||||
else:
|
||||
info["folders"][folder_name] = {"exists": False}
|
||||
except Exception as e:
|
||||
info["error"] = str(e)
|
||||
|
||||
return info
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
|
||||
print(f"🚀 Mac Mini AI 번역 서버")
|
||||
print(f"📡 서버 주소: http://{MAC_MINI_IP}:8080")
|
||||
print(f"📁 NAS Mount: {NAS_MOUNT_POINT}")
|
||||
|
||||
# 시작 전 연결 확인
|
||||
nas_status = check_nas_connection()
|
||||
if nas_status["status"] == "connected":
|
||||
print(f"✅ NAS 연결 확인됨")
|
||||
ensure_nas_directories()
|
||||
else:
|
||||
print(f"❌ NAS 연결 문제: {nas_status['error']}")
|
||||
|
||||
uvicorn.run(app, host="0.0.0.0", port=8080)
|
||||
848
src/fastapi_port_20080.py
Normal file
848
src/fastapi_port_20080.py
Normal file
@@ -0,0 +1,848 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
포트 20080으로 실행하는 AI 번역 시스템
|
||||
Mac Mini (192.168.1.122:20080) + Media Mount 연동
|
||||
"""
|
||||
|
||||
from fastapi import FastAPI, File, UploadFile, HTTPException, BackgroundTasks, Request
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import HTMLResponse, FileResponse, JSONResponse
|
||||
import asyncio
|
||||
import json
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
import time
|
||||
import shutil
|
||||
from dataclasses import dataclass, asdict
|
||||
import aiofiles
|
||||
from datetime import datetime
|
||||
import subprocess
|
||||
|
||||
# 실제 네트워크 설정 (포트 20080)
|
||||
MAC_MINI_IP = "192.168.1.122"
|
||||
MAC_MINI_PORT = 20080
|
||||
NAS_IP = "192.168.1.227"
|
||||
|
||||
# 기존 연결된 Media 마운트 사용
|
||||
NAS_MOUNT_POINT = Path("/Volumes/Media")
|
||||
DOCUMENT_UPLOAD_BASE = NAS_MOUNT_POINT / "Document-upload"
|
||||
|
||||
# 세부 경로들
|
||||
NAS_ORIGINALS_PATH = DOCUMENT_UPLOAD_BASE / "originals"
|
||||
NAS_TRANSLATED_PATH = DOCUMENT_UPLOAD_BASE / "translated"
|
||||
NAS_STATIC_HOSTING_PATH = DOCUMENT_UPLOAD_BASE / "static-hosting"
|
||||
NAS_METADATA_PATH = DOCUMENT_UPLOAD_BASE / "metadata"
|
||||
|
||||
# 로컬 작업 디렉토리
|
||||
LOCAL_WORK_PATH = Path.home() / "Scripts" / "nllb-translation-system"
|
||||
|
||||
app = FastAPI(
|
||||
title="AI 번역 시스템",
|
||||
description=f"Mac Mini ({MAC_MINI_IP}:{MAC_MINI_PORT}) + Media Mount 연동",
|
||||
version="1.0.0"
|
||||
)
|
||||
|
||||
# 정적 파일 및 템플릿 (있는 경우에만)
|
||||
if (LOCAL_WORK_PATH / "static").exists():
|
||||
app.mount("/static", StaticFiles(directory=str(LOCAL_WORK_PATH / "static")), name="static")
|
||||
|
||||
if (LOCAL_WORK_PATH / "templates").exists():
|
||||
templates = Jinja2Templates(directory=str(LOCAL_WORK_PATH / "templates"))
|
||||
else:
|
||||
templates = None
|
||||
|
||||
# 작업 상태 관리
|
||||
processing_jobs: Dict[str, Dict] = {}
|
||||
|
||||
def check_nas_connection():
|
||||
"""NAS 연결 상태 확인"""
|
||||
try:
|
||||
# 1. Media 마운트 확인
|
||||
if not NAS_MOUNT_POINT.exists():
|
||||
return {"status": "not_mounted", "error": "Media 마운트 포인트 없음"}
|
||||
|
||||
# 2. 쓰기 권한 확인
|
||||
try:
|
||||
test_file = NAS_MOUNT_POINT / ".test_write"
|
||||
test_file.touch()
|
||||
test_file.unlink()
|
||||
except:
|
||||
return {"status": "read_only", "error": "Media 마운트 읽기 전용"}
|
||||
|
||||
# 3. Document-upload 폴더 확인
|
||||
if not DOCUMENT_UPLOAD_BASE.exists():
|
||||
return {"status": "folder_missing", "error": "Document-upload 폴더 없음"}
|
||||
|
||||
return {"status": "connected", "mount_point": str(NAS_MOUNT_POINT)}
|
||||
|
||||
except Exception as e:
|
||||
return {"status": "error", "error": str(e)}
|
||||
|
||||
def ensure_nas_directories():
|
||||
"""NAS 디렉토리 구조 생성"""
|
||||
try:
|
||||
# Document-upload 기본 구조
|
||||
DOCUMENT_UPLOAD_BASE.mkdir(exist_ok=True)
|
||||
|
||||
current_month = datetime.now().strftime("%Y-%m")
|
||||
|
||||
folders_to_create = [
|
||||
NAS_ORIGINALS_PATH / current_month / "pdfs",
|
||||
NAS_ORIGINALS_PATH / current_month / "docs",
|
||||
NAS_ORIGINALS_PATH / current_month / "txts",
|
||||
NAS_TRANSLATED_PATH / current_month / "english-to-korean",
|
||||
NAS_TRANSLATED_PATH / current_month / "japanese-to-korean",
|
||||
NAS_TRANSLATED_PATH / current_month / "korean-only",
|
||||
NAS_STATIC_HOSTING_PATH / "docs",
|
||||
NAS_STATIC_HOSTING_PATH / "assets",
|
||||
NAS_STATIC_HOSTING_PATH / "index",
|
||||
NAS_METADATA_PATH / "processing-logs"
|
||||
]
|
||||
|
||||
for folder in folders_to_create:
|
||||
folder.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# README 파일 생성
|
||||
readme_content = f"""# AI 번역 시스템 문서 저장소
|
||||
|
||||
자동 생성 시간: {datetime.now().isoformat()}
|
||||
Mac Mini: {MAC_MINI_IP}:{MAC_MINI_PORT}
|
||||
NAS IP: {NAS_IP}
|
||||
|
||||
## 접속 정보
|
||||
|
||||
- 웹 인터페이스: http://{MAC_MINI_IP}:{MAC_MINI_PORT}
|
||||
- 시스템 상태: http://{MAC_MINI_IP}:{MAC_MINI_PORT}/system-status
|
||||
- NAS 정보: http://{MAC_MINI_IP}:{MAC_MINI_PORT}/nas-info
|
||||
- API 문서: http://{MAC_MINI_IP}:{MAC_MINI_PORT}/docs
|
||||
|
||||
## 폴더 구조
|
||||
|
||||
- originals/: 업로드된 원본 파일들
|
||||
- translated/: 번역된 HTML 파일들
|
||||
- static-hosting/: 웹 호스팅용 파일들
|
||||
- metadata/: 처리 로그 및 메타데이터
|
||||
|
||||
## VPN 접속
|
||||
|
||||
내부 네트워크에서만 접근 가능합니다.
|
||||
외부에서 접속시 VPN 연결이 필요합니다.
|
||||
"""
|
||||
|
||||
readme_path = DOCUMENT_UPLOAD_BASE / "README.md"
|
||||
with open(readme_path, 'w', encoding='utf-8') as f:
|
||||
f.write(readme_content)
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 디렉토리 생성 실패: {e}")
|
||||
return False
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup_event():
|
||||
"""서버 시작시 전체 시스템 상태 확인"""
|
||||
print(f"🚀 Mac Mini AI 번역 서버 시작")
|
||||
print(f"📍 접속 주소: http://{MAC_MINI_IP}:{MAC_MINI_PORT}")
|
||||
print(f"📍 NAS Mount: {NAS_MOUNT_POINT}")
|
||||
print("-" * 60)
|
||||
|
||||
# NAS 연결 상태 확인
|
||||
nas_status = check_nas_connection()
|
||||
|
||||
if nas_status["status"] == "connected":
|
||||
print(f"✅ NAS 연결 정상: {nas_status['mount_point']}")
|
||||
|
||||
if ensure_nas_directories():
|
||||
print(f"✅ 폴더 구조 확인/생성 완료")
|
||||
print(f"📁 문서 저장 경로: {DOCUMENT_UPLOAD_BASE}")
|
||||
else:
|
||||
print(f"⚠️ 폴더 생성 실패")
|
||||
else:
|
||||
print(f"❌ NAS 연결 실패: {nas_status['error']}")
|
||||
|
||||
@app.get("/")
|
||||
async def index(request: Request = None):
|
||||
"""메인 페이지"""
|
||||
nas_status = check_nas_connection()
|
||||
|
||||
if templates:
|
||||
return templates.TemplateResponse("index.html", {
|
||||
"request": request,
|
||||
"nas_status": nas_status
|
||||
})
|
||||
else:
|
||||
# 포트 20080 반영된 기본 HTML
|
||||
html_content = f"""
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>🤖 AI 번역 시스템</title>
|
||||
<style>
|
||||
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
|
||||
body {{
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
}}
|
||||
.container {{
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
background: white;
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 20px 40px rgba(0,0,0,0.1);
|
||||
overflow: hidden;
|
||||
}}
|
||||
.header {{
|
||||
background: linear-gradient(135deg, #2563eb, #3b82f6);
|
||||
color: white;
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
}}
|
||||
.header h1 {{ font-size: 2.5rem; margin-bottom: 10px; }}
|
||||
.header p {{ font-size: 1.1rem; opacity: 0.9; }}
|
||||
.server-info {{
|
||||
background: #f0f9ff;
|
||||
padding: 20px;
|
||||
border-bottom: 1px solid #bae6fd;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 20px;
|
||||
}}
|
||||
.info-item {{ display: flex; align-items: center; gap: 10px; color: #0369a1; }}
|
||||
.status {{ padding: 20px; border-radius: 8px; margin: 20px; }}
|
||||
.status-good {{ background: #d1fae5; color: #065f46; }}
|
||||
.status-bad {{ background: #fee2e2; color: #dc2626; }}
|
||||
.main-content {{ padding: 40px; }}
|
||||
.upload-area {{
|
||||
border: 3px dashed #e5e7eb;
|
||||
padding: 60px 40px;
|
||||
text-align: center;
|
||||
border-radius: 16px;
|
||||
transition: all 0.3s ease;
|
||||
margin: 20px 0;
|
||||
}}
|
||||
.upload-area:hover {{ border-color: #2563eb; background: #f8fafc; }}
|
||||
.upload-area h3 {{ font-size: 1.5rem; margin-bottom: 20px; color: #374151; }}
|
||||
input[type="file"] {{ margin: 20px 0; padding: 10px; }}
|
||||
button {{
|
||||
background: #2563eb;
|
||||
color: white;
|
||||
padding: 15px 30px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
transition: background 0.3s ease;
|
||||
}}
|
||||
button:hover {{ background: #1d4ed8; }}
|
||||
button:disabled {{ background: #9ca3af; cursor: not-allowed; }}
|
||||
.progress {{ margin: 20px 0; padding: 20px; background: #f8fafc; border-radius: 12px; display: none; }}
|
||||
.progress-bar {{ width: 100%; height: 8px; background: #e5e7eb; border-radius: 4px; overflow: hidden; }}
|
||||
.progress-fill {{ height: 100%; background: #2563eb; width: 0%; transition: width 0.3s ease; }}
|
||||
.links {{ margin: 30px 0; }}
|
||||
.links h3 {{ margin-bottom: 15px; color: #374151; }}
|
||||
.links ul {{ list-style: none; }}
|
||||
.links li {{ margin: 8px 0; }}
|
||||
.links a {{
|
||||
color: #2563eb;
|
||||
text-decoration: none;
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
transition: background 0.3s ease;
|
||||
}}
|
||||
.links a:hover {{ background: #eff6ff; }}
|
||||
.result {{
|
||||
margin: 20px 0;
|
||||
padding: 20px;
|
||||
background: #f0f9ff;
|
||||
border: 1px solid #0ea5e9;
|
||||
border-radius: 12px;
|
||||
display: none;
|
||||
}}
|
||||
.result h4 {{ color: #0369a1; margin-bottom: 15px; }}
|
||||
.result-actions {{ display: flex; gap: 15px; }}
|
||||
.btn {{
|
||||
padding: 10px 20px;
|
||||
border-radius: 6px;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
transition: all 0.3s ease;
|
||||
}}
|
||||
.btn-primary {{ background: #2563eb; color: white; }}
|
||||
.btn-secondary {{ background: #f8fafc; color: #374151; border: 1px solid #e5e7eb; }}
|
||||
@media (max-width: 768px) {{
|
||||
.server-info {{ grid-template-columns: 1fr; }}
|
||||
.result-actions {{ flex-direction: column; }}
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>🤖 AI 번역 시스템</h1>
|
||||
<p>PDF/문서를 다국어 HTML로 자동 변환</p>
|
||||
</div>
|
||||
|
||||
<div class="server-info">
|
||||
<div class="info-item">
|
||||
<span>🖥️</span>
|
||||
<span>Mac Mini: {MAC_MINI_IP}:{MAC_MINI_PORT}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span>💾</span>
|
||||
<span>NAS: {NAS_IP} (DS1525+)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="status {'status-good' if nas_status['status'] == 'connected' else 'status-bad'}">
|
||||
{'✅ NAS 연결됨: ' + nas_status.get('mount_point', '') if nas_status['status'] == 'connected' else '❌ NAS 연결 실패: ' + nas_status.get('error', '')}
|
||||
</div>
|
||||
|
||||
<div class="main-content">
|
||||
<div class="upload-area">
|
||||
<h3>📁 파일 업로드</h3>
|
||||
<p style="color: #6b7280; margin-bottom: 20px;">PDF, TXT, DOCX 파일 지원 (최대 100MB)</p>
|
||||
<form id="uploadForm" enctype="multipart/form-data">
|
||||
<input type="file" id="fileInput" accept=".pdf,.txt,.docx,.doc" style="margin: 20px 0;" />
|
||||
<br>
|
||||
<button type="submit" {'disabled' if nas_status['status'] != 'connected' else ''}>
|
||||
{'📤 파일 업로드' if nas_status['status'] == 'connected' else '❌ NAS 연결 필요'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div id="progress" class="progress">
|
||||
<div style="margin-bottom: 10px; font-weight: 500;" id="progressText">처리 중...</div>
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" id="progressFill"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="result" class="result">
|
||||
<h4>🎉 변환 완료!</h4>
|
||||
<div class="result-actions">
|
||||
<a href="#" id="downloadBtn" class="btn btn-primary">📥 HTML 다운로드</a>
|
||||
<a href="http://{MAC_MINI_IP}:{MAC_MINI_PORT}/nas-info" class="btn btn-secondary">📊 NAS 정보</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="links">
|
||||
<h3>🔗 시스템 링크</h3>
|
||||
<ul>
|
||||
<li><a href="http://{MAC_MINI_IP}:{MAC_MINI_PORT}/system-status" target="_blank">📊 시스템 상태</a></li>
|
||||
<li><a href="http://{MAC_MINI_IP}:{MAC_MINI_PORT}/nas-info" target="_blank">💾 NAS 정보</a></li>
|
||||
<li><a href="http://{MAC_MINI_IP}:{MAC_MINI_PORT}/docs" target="_blank">📖 API 문서</a></li>
|
||||
<li><a href="/Volumes/Media/Document-upload" onclick="alert('Finder에서 열기: /Volumes/Media/Document-upload'); return false;">📁 NAS 폴더 (로컬)</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let currentJobId = null;
|
||||
|
||||
document.getElementById('uploadForm').onsubmit = async function(e) {{
|
||||
e.preventDefault();
|
||||
|
||||
const fileInput = document.getElementById('fileInput');
|
||||
const progressDiv = document.getElementById('progress');
|
||||
const resultDiv = document.getElementById('result');
|
||||
|
||||
if (!fileInput.files[0]) {{
|
||||
alert('파일을 선택해주세요.');
|
||||
return;
|
||||
}}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', fileInput.files[0]);
|
||||
|
||||
// UI 업데이트
|
||||
progressDiv.style.display = 'block';
|
||||
resultDiv.style.display = 'none';
|
||||
document.getElementById('progressText').textContent = '업로드 중...';
|
||||
document.getElementById('progressFill').style.width = '10%';
|
||||
|
||||
try {{
|
||||
const response = await fetch('/upload', {{
|
||||
method: 'POST',
|
||||
body: formData
|
||||
}});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok) {{
|
||||
currentJobId = result.job_id;
|
||||
document.getElementById('progressText').textContent = '업로드 완료, 처리 시작...';
|
||||
document.getElementById('progressFill').style.width = '20%';
|
||||
|
||||
// 진행 상황 모니터링
|
||||
monitorJob();
|
||||
}} else {{
|
||||
throw new Error(result.detail || '업로드 실패');
|
||||
}}
|
||||
}} catch (error) {{
|
||||
progressDiv.style.display = 'none';
|
||||
alert('업로드 실패: ' + error.message);
|
||||
}}
|
||||
}};
|
||||
|
||||
async function monitorJob() {{
|
||||
if (!currentJobId) return;
|
||||
|
||||
try {{
|
||||
const response = await fetch(`/status/${{currentJobId}}`);
|
||||
const status = await response.json();
|
||||
|
||||
// 진행률 업데이트
|
||||
document.getElementById('progressText').textContent = status.message;
|
||||
document.getElementById('progressFill').style.width = status.progress + '%';
|
||||
|
||||
if (status.status === 'completed') {{
|
||||
// 완료 처리
|
||||
document.getElementById('progress').style.display = 'none';
|
||||
document.getElementById('result').style.display = 'block';
|
||||
document.getElementById('downloadBtn').href = `/download/${{currentJobId}}`;
|
||||
|
||||
}} else if (status.status === 'error') {{
|
||||
document.getElementById('progress').style.display = 'none';
|
||||
alert('처리 실패: ' + status.message);
|
||||
|
||||
}} else {{
|
||||
// 계속 모니터링
|
||||
setTimeout(monitorJob, 2000);
|
||||
}}
|
||||
|
||||
}} catch (error) {{
|
||||
console.error('상태 확인 오류:', error);
|
||||
setTimeout(monitorJob, 5000);
|
||||
}}
|
||||
}}
|
||||
|
||||
// 페이지 로드시 서버 상태 확인
|
||||
window.onload = function() {{
|
||||
console.log('🚀 AI 번역 시스템 로드 완료');
|
||||
console.log('📍 서버: http://{MAC_MINI_IP}:{MAC_MINI_PORT}');
|
||||
}};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
return HTMLResponse(content=html_content)
|
||||
|
||||
@app.get("/system-status")
|
||||
async def system_status():
|
||||
"""전체 시스템 상태"""
|
||||
nas_status = check_nas_connection()
|
||||
|
||||
# 파일 통계
|
||||
file_stats = {"originals": 0, "translated": 0, "total_size": 0}
|
||||
|
||||
try:
|
||||
if NAS_ORIGINALS_PATH.exists():
|
||||
original_files = list(NAS_ORIGINALS_PATH.rglob("*.*"))
|
||||
file_stats["originals"] = len([f for f in original_files if f.is_file()])
|
||||
|
||||
if NAS_TRANSLATED_PATH.exists():
|
||||
html_files = list(NAS_TRANSLATED_PATH.rglob("*.html"))
|
||||
file_stats["translated"] = len(html_files)
|
||||
file_stats["total_size"] = sum(f.stat().st_size for f in html_files if f.exists())
|
||||
except:
|
||||
pass
|
||||
|
||||
return {
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"server": f"{MAC_MINI_IP}:{MAC_MINI_PORT}",
|
||||
"nas_ip": NAS_IP,
|
||||
"nas_mount": str(NAS_MOUNT_POINT),
|
||||
"nas_status": nas_status,
|
||||
"file_stats": file_stats,
|
||||
"active_jobs": len(processing_jobs),
|
||||
"document_upload_path": str(DOCUMENT_UPLOAD_BASE),
|
||||
"access_urls": {
|
||||
"main": f"http://{MAC_MINI_IP}:{MAC_MINI_PORT}",
|
||||
"system_status": f"http://{MAC_MINI_IP}:{MAC_MINI_PORT}/system-status",
|
||||
"nas_info": f"http://{MAC_MINI_IP}:{MAC_MINI_PORT}/nas-info",
|
||||
"api_docs": f"http://{MAC_MINI_IP}:{MAC_MINI_PORT}/docs"
|
||||
}
|
||||
}
|
||||
|
||||
@app.post("/upload")
|
||||
async def upload_file(background_tasks: BackgroundTasks, file: UploadFile = File(...)):
|
||||
"""파일 업로드"""
|
||||
|
||||
# NAS 연결 상태 먼저 확인
|
||||
nas_status = check_nas_connection()
|
||||
if nas_status["status"] != "connected":
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail=f"NAS 연결 실패: {nas_status['error']}"
|
||||
)
|
||||
|
||||
# 파일 검증
|
||||
if not file.filename:
|
||||
raise HTTPException(status_code=400, detail="파일이 선택되지 않았습니다.")
|
||||
|
||||
allowed_extensions = {'.pdf', '.txt', '.docx', '.doc'}
|
||||
file_ext = Path(file.filename).suffix.lower()
|
||||
|
||||
if file_ext not in allowed_extensions:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"지원되지 않는 파일 형식입니다. 지원: {', '.join(allowed_extensions)}"
|
||||
)
|
||||
|
||||
# 파일 크기 확인 (100MB 제한)
|
||||
content = await file.read()
|
||||
if len(content) > 100 * 1024 * 1024:
|
||||
raise HTTPException(status_code=413, detail="파일 크기는 100MB를 초과할 수 없습니다.")
|
||||
|
||||
# 고유 작업 ID 생성
|
||||
job_id = str(uuid.uuid4())
|
||||
|
||||
# NAS에 저장
|
||||
current_month = datetime.now().strftime("%Y-%m")
|
||||
file_type_folder = {
|
||||
'.pdf': 'pdfs',
|
||||
'.doc': 'docs',
|
||||
'.docx': 'docs',
|
||||
'.txt': 'txts'
|
||||
}.get(file_ext, 'others')
|
||||
|
||||
nas_original_dir = NAS_ORIGINALS_PATH / current_month / file_type_folder
|
||||
nas_original_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 안전한 파일명 생성
|
||||
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||
safe_filename = f"{timestamp}_{job_id[:8]}_{file.filename}".replace(" ", "_")
|
||||
nas_file_path = nas_original_dir / safe_filename
|
||||
|
||||
# 파일 저장
|
||||
try:
|
||||
async with aiofiles.open(nas_file_path, 'wb') as f:
|
||||
await f.write(content)
|
||||
|
||||
print(f"📁 파일 저장: {nas_file_path}")
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"파일 저장 실패: {str(e)}")
|
||||
|
||||
# 작업 상태 초기화
|
||||
job = {
|
||||
"id": job_id,
|
||||
"filename": file.filename,
|
||||
"file_type": file_ext,
|
||||
"status": "uploaded",
|
||||
"progress": 20,
|
||||
"message": f"파일 저장 완료: {safe_filename}",
|
||||
"nas_original_path": str(nas_file_path),
|
||||
"created_at": time.time(),
|
||||
"server": f"{MAC_MINI_IP}:{MAC_MINI_PORT}"
|
||||
}
|
||||
|
||||
processing_jobs[job_id] = job
|
||||
|
||||
# 백그라운드 처리 시작
|
||||
background_tasks.add_task(process_document_simple, job_id, nas_file_path)
|
||||
|
||||
return {
|
||||
"job_id": job_id,
|
||||
"message": "파일 업로드 완료, 처리를 시작합니다.",
|
||||
"nas_path": str(nas_file_path),
|
||||
"server": f"{MAC_MINI_IP}:{MAC_MINI_PORT}"
|
||||
}
|
||||
|
||||
async def process_document_simple(job_id: str, nas_file_path: Path):
|
||||
"""간단한 문서 처리 (AI 모델 연동 전 테스트용)"""
|
||||
|
||||
try:
|
||||
processing_jobs[job_id].update({
|
||||
"status": "processing",
|
||||
"progress": 40,
|
||||
"message": "텍스트 추출 중..."
|
||||
})
|
||||
|
||||
await asyncio.sleep(2) # 시뮬레이션
|
||||
|
||||
processing_jobs[job_id].update({
|
||||
"progress": 70,
|
||||
"message": "언어 감지 및 번역 중..."
|
||||
})
|
||||
|
||||
await asyncio.sleep(3) # 시뮬레이션
|
||||
|
||||
# 간단한 HTML 생성 (테스트용)
|
||||
current_month = datetime.now().strftime("%Y-%m")
|
||||
nas_translated_dir = NAS_TRANSLATED_PATH / current_month / "korean-only"
|
||||
nas_translated_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
base_name = Path(nas_file_path.stem).stem
|
||||
if '_' in base_name:
|
||||
parts = base_name.split('_')[2:] # 타임스탬프와 job_id 제거
|
||||
if parts:
|
||||
base_name = '_'.join(parts)
|
||||
|
||||
html_filename = f"{base_name}.html"
|
||||
nas_html_path = nas_translated_dir / html_filename
|
||||
|
||||
# 테스트용 HTML 생성
|
||||
html_content = f"""<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{base_name} - AI 번역 결과</title>
|
||||
<style>
|
||||
body {{
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
margin: 0;
|
||||
padding: 40px;
|
||||
line-height: 1.6;
|
||||
background: #f8fafc;
|
||||
}}
|
||||
.container {{ max-width: 900px; margin: 0 auto; background: white; border-radius: 12px; overflow: hidden; box-shadow: 0 10px 25px rgba(0,0,0,0.1); }}
|
||||
.header {{
|
||||
background: linear-gradient(135deg, #2563eb, #3b82f6);
|
||||
color: white;
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
}}
|
||||
.header h1 {{ font-size: 2rem; margin-bottom: 10px; }}
|
||||
.meta {{ background: #f0f9ff; padding: 20px; border-bottom: 1px solid #bae6fd; }}
|
||||
.meta-grid {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 15px; }}
|
||||
.meta-item {{ display: flex; align-items: center; gap: 8px; color: #0369a1; }}
|
||||
.content {{ padding: 40px; }}
|
||||
.section {{ margin-bottom: 30px; }}
|
||||
.section h2 {{ color: #1f2937; border-bottom: 2px solid #e5e7eb; padding-bottom: 10px; }}
|
||||
.info-box {{ background: #f8fafc; border: 1px solid #e5e7eb; border-radius: 8px; padding: 20px; margin: 20px 0; }}
|
||||
.footer {{ background: #f8fafc; padding: 20px 40px; text-align: center; color: #6b7280; font-size: 0.9rem; }}
|
||||
.system-info {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 15px; }}
|
||||
.system-card {{ background: white; border: 1px solid #e5e7eb; border-radius: 8px; padding: 15px; }}
|
||||
@media (max-width: 768px) {{
|
||||
body {{ padding: 20px; }}
|
||||
.meta-grid, .system-info {{ grid-template-columns: 1fr; }}
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>📄 {base_name}</h1>
|
||||
<p>AI 번역 시스템 처리 결과</p>
|
||||
</div>
|
||||
|
||||
<div class="meta">
|
||||
<div class="meta-grid">
|
||||
<div class="meta-item">
|
||||
<span>⏰</span>
|
||||
<span>{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}</span>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<span>📁</span>
|
||||
<span>{nas_file_path.name}</span>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<span>🆔</span>
|
||||
<span>{job_id[:8]}</span>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<span>🖥️</span>
|
||||
<span>{MAC_MINI_IP}:{MAC_MINI_PORT}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<div class="section">
|
||||
<h2>📋 문서 정보</h2>
|
||||
<div class="info-box">
|
||||
<p><strong>원본 파일:</strong> {nas_file_path.name}</p>
|
||||
<p><strong>저장 위치:</strong> {nas_html_path}</p>
|
||||
<p><strong>처리 서버:</strong> Mac Mini ({MAC_MINI_IP}:{MAC_MINI_PORT})</p>
|
||||
<p><strong>NAS 저장소:</strong> DS1525+ ({NAS_IP})</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>🤖 AI 처리 결과</h2>
|
||||
<div class="info-box">
|
||||
<h3>테스트 모드</h3>
|
||||
<p>현재 AI 번역 시스템이 테스트 모드로 실행 중입니다.</p>
|
||||
<p>실제 AI 모델(NLLB, KoBART)이 연동되면 이 부분에 다음 내용이 표시됩니다:</p>
|
||||
<ul style="margin-left: 20px;">
|
||||
<li>자동 언어 감지 결과</li>
|
||||
<li>한국어 번역 텍스트</li>
|
||||
<li>문서 요약</li>
|
||||
<li>다국어 지원 인터페이스</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>🌐 시스템 구성</h2>
|
||||
<div class="system-info">
|
||||
<div class="system-card">
|
||||
<h4>Mac Mini M4 Pro</h4>
|
||||
<p>IP: {MAC_MINI_IP}:{MAC_MINI_PORT}</p>
|
||||
<p>역할: AI 처리 서버</p>
|
||||
<p>모델: NLLB + KoBART</p>
|
||||
</div>
|
||||
<div class="system-card">
|
||||
<h4>Synology DS1525+</h4>
|
||||
<p>IP: {NAS_IP}</p>
|
||||
<p>역할: 파일 저장소</p>
|
||||
<p>마운트: /Volumes/Media</p>
|
||||
</div>
|
||||
<div class="system-card">
|
||||
<h4>네트워크</h4>
|
||||
<p>연결: 2.5GbE</p>
|
||||
<p>접근: VPN 필요</p>
|
||||
<p>프로토콜: SMB</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>🔗 관련 링크</h2>
|
||||
<div class="info-box">
|
||||
<p><a href="http://{MAC_MINI_IP}:{MAC_MINI_PORT}" target="_blank">🏠 메인 페이지</a></p>
|
||||
<p><a href="http://{MAC_MINI_IP}:{MAC_MINI_PORT}/system-status" target="_blank">📊 시스템 상태</a></p>
|
||||
<p><a href="http://{MAC_MINI_IP}:{MAC_MINI_PORT}/nas-info" target="_blank">💾 NAS 정보</a></p>
|
||||
<p><a href="http://{MAC_MINI_IP}:{MAC_MINI_PORT}/docs" target="_blank">📖 API 문서</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<p>AI 번역 시스템 v1.0.0 | Mac Mini M4 Pro + Synology DS1525+ | 자동 생성 문서</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
# HTML 파일 저장
|
||||
async with aiofiles.open(nas_html_path, 'w', encoding='utf-8') as f:
|
||||
await f.write(html_content)
|
||||
|
||||
processing_jobs[job_id].update({
|
||||
"progress": 100,
|
||||
"status": "completed",
|
||||
"message": "처리 완료!",
|
||||
"nas_translated_path": str(nas_html_path)
|
||||
})
|
||||
|
||||
print(f"✅ 테스트 처리 완료: {job_id} - {html_filename}")
|
||||
|
||||
except Exception as e:
|
||||
processing_jobs[job_id].update({
|
||||
"status": "error",
|
||||
"progress": 0,
|
||||
"message": f"처리 실패: {str(e)}"
|
||||
})
|
||||
|
||||
print(f"❌ 처리 실패: {job_id} - {str(e)}")
|
||||
|
||||
@app.get("/status/{job_id}")
|
||||
async def get_job_status(job_id: str):
|
||||
"""작업 상태 조회"""
|
||||
if job_id not in processing_jobs:
|
||||
raise HTTPException(status_code=404, detail="작업을 찾을 수 없습니다.")
|
||||
|
||||
return processing_jobs[job_id]
|
||||
|
||||
@app.get("/download/{job_id}")
|
||||
async def download_result(job_id: str):
|
||||
"""결과 파일 다운로드"""
|
||||
if job_id not in processing_jobs:
|
||||
raise HTTPException(status_code=404, detail="작업을 찾을 수 없습니다.")
|
||||
|
||||
job = processing_jobs[job_id]
|
||||
|
||||
if job["status"] != "completed" or not job.get("nas_translated_path"):
|
||||
raise HTTPException(status_code=400, detail="처리가 완료되지 않았습니다.")
|
||||
|
||||
result_path = Path(job["nas_translated_path"])
|
||||
if not result_path.exists():
|
||||
raise HTTPException(status_code=404, detail="결과 파일을 찾을 수 없습니다.")
|
||||
|
||||
return FileResponse(
|
||||
path=result_path,
|
||||
filename=f"{Path(job['filename']).stem}.html",
|
||||
media_type="text/html"
|
||||
)
|
||||
|
||||
@app.get("/nas-info")
|
||||
async def nas_info():
|
||||
"""NAS 정보 및 통계"""
|
||||
|
||||
nas_status = check_nas_connection()
|
||||
|
||||
info = {
|
||||
"server": f"{MAC_MINI_IP}:{MAC_MINI_PORT}",
|
||||
"nas_ip": NAS_IP,
|
||||
"nas_status": nas_status,
|
||||
"mount_point": str(NAS_MOUNT_POINT),
|
||||
"document_upload_base": str(DOCUMENT_UPLOAD_BASE),
|
||||
"folders": {},
|
||||
"statistics": {
|
||||
"total_jobs": len(processing_jobs),
|
||||
"completed_jobs": len([j for j in processing_jobs.values() if j["status"] == "completed"]),
|
||||
"processing_jobs": len([j for j in processing_jobs.values() if j["status"] == "processing"]),
|
||||
"failed_jobs": len([j for j in processing_jobs.values() if j["status"] == "error"])
|
||||
},
|
||||
"recent_jobs": list(processing_jobs.values())[-5:] if processing_jobs else []
|
||||
}
|
||||
|
||||
# 폴더 정보 수집
|
||||
try:
|
||||
for folder_name, folder_path in [
|
||||
("originals", NAS_ORIGINALS_PATH),
|
||||
("translated", NAS_TRANSLATED_PATH),
|
||||
("static-hosting", NAS_STATIC_HOSTING_PATH),
|
||||
("metadata", NAS_METADATA_PATH)
|
||||
]:
|
||||
if folder_path.exists():
|
||||
files = list(folder_path.rglob("*.*"))
|
||||
file_count = len([f for f in files if f.is_file()])
|
||||
total_size = sum(f.stat().st_size for f in files if f.is_file())
|
||||
|
||||
info["folders"][folder_name] = {
|
||||
"exists": True,
|
||||
"path": str(folder_path),
|
||||
"file_count": file_count,
|
||||
"total_size_mb": round(total_size / (1024 * 1024), 2)
|
||||
}
|
||||
else:
|
||||
info["folders"][folder_name] = {
|
||||
"exists": False,
|
||||
"path": str(folder_path)
|
||||
}
|
||||
except Exception as e:
|
||||
info["error"] = str(e)
|
||||
|
||||
return info
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
|
||||
print(f"🚀 Mac Mini AI 번역 서버")
|
||||
print(f"📡 접속 주소: http://{MAC_MINI_IP}:{MAC_MINI_PORT}")
|
||||
print(f"📁 NAS Mount: {NAS_MOUNT_POINT}")
|
||||
print(f"🔗 VPN 접속 전용 (내부 네트워크)")
|
||||
|
||||
# 시작 전 연결 확인
|
||||
nas_status = check_nas_connection()
|
||||
if nas_status["status"] == "connected":
|
||||
print(f"✅ NAS 연결 확인됨")
|
||||
ensure_nas_directories()
|
||||
else:
|
||||
print(f"❌ NAS 연결 문제: {nas_status['error']}")
|
||||
|
||||
print("-" * 60)
|
||||
uvicorn.run(app, host="0.0.0.0", port=MAC_MINI_PORT)
|
||||
597
src/html_generator.py
Executable file
597
src/html_generator.py
Executable file
@@ -0,0 +1,597 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
다국어 HTML 생성기
|
||||
원문 + 번역문을 언어 전환 가능한 HTML로 생성
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Dict, List
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime
|
||||
|
||||
class MultilingualHTMLGenerator:
|
||||
def __init__(self):
|
||||
self.templates = self._load_templates()
|
||||
|
||||
def _load_templates(self) -> Dict:
|
||||
"""HTML 템플릿들"""
|
||||
return {
|
||||
"base": """<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{title}</title>
|
||||
<style>
|
||||
:root {{
|
||||
--primary-color: #2563eb;
|
||||
--secondary-color: #64748b;
|
||||
--background-color: #f8fafc;
|
||||
--text-color: #1e293b;
|
||||
--border-color: #e2e8f0;
|
||||
--shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
}}
|
||||
|
||||
* {{
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}}
|
||||
|
||||
body {{
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Noto Sans KR', sans-serif;
|
||||
line-height: 1.6;
|
||||
color: var(--text-color);
|
||||
background: var(--background-color);
|
||||
}}
|
||||
|
||||
.container {{
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}}
|
||||
|
||||
.header {{
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
margin-bottom: 24px;
|
||||
box-shadow: var(--shadow);
|
||||
border: 1px solid var(--border-color);
|
||||
}}
|
||||
|
||||
.title {{
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: var(--primary-color);
|
||||
margin-bottom: 12px;
|
||||
}}
|
||||
|
||||
.metadata {{
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 16px;
|
||||
font-size: 0.875rem;
|
||||
color: var(--secondary-color);
|
||||
}}
|
||||
|
||||
.metadata-item {{
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}}
|
||||
|
||||
.metadata-icon {{
|
||||
font-size: 1.2em;
|
||||
}}
|
||||
|
||||
.language-switcher {{
|
||||
position: fixed;
|
||||
top: 24px;
|
||||
right: 24px;
|
||||
z-index: 1000;
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
box-shadow: var(--shadow);
|
||||
border: 1px solid var(--border-color);
|
||||
}}
|
||||
|
||||
.language-buttons {{
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}}
|
||||
|
||||
.lang-btn {{
|
||||
padding: 8px 16px;
|
||||
border: 2px solid var(--border-color);
|
||||
background: white;
|
||||
color: var(--text-color);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
font-size: 0.875rem;
|
||||
}}
|
||||
|
||||
.lang-btn:hover {{
|
||||
border-color: var(--primary-color);
|
||||
color: var(--primary-color);
|
||||
}}
|
||||
|
||||
.lang-btn.active {{
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
border-color: var(--primary-color);
|
||||
}}
|
||||
|
||||
.content-section {{
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 24px;
|
||||
box-shadow: var(--shadow);
|
||||
border: 1px solid var(--border-color);
|
||||
overflow: hidden;
|
||||
}}
|
||||
|
||||
.section-header {{
|
||||
background: linear-gradient(135deg, var(--primary-color), #3b82f6);
|
||||
color: white;
|
||||
padding: 16px 24px;
|
||||
font-weight: 600;
|
||||
font-size: 1.1rem;
|
||||
}}
|
||||
|
||||
.section-content {{
|
||||
padding: 24px;
|
||||
}}
|
||||
|
||||
.language-content {{
|
||||
display: none;
|
||||
}}
|
||||
|
||||
.language-content.active {{
|
||||
display: block;
|
||||
}}
|
||||
|
||||
.text-content {{
|
||||
font-size: 1rem;
|
||||
line-height: 1.8;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
}}
|
||||
|
||||
.summary-box {{
|
||||
background: linear-gradient(135deg, #f0f9ff, #e0f2fe);
|
||||
border: 1px solid #0ea5e9;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
}}
|
||||
|
||||
.summary-title {{
|
||||
font-weight: 600;
|
||||
color: #0369a1;
|
||||
margin-bottom: 12px;
|
||||
font-size: 1.1rem;
|
||||
}}
|
||||
|
||||
.toc {{
|
||||
background: #f8fafc;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin-bottom: 24px;
|
||||
}}
|
||||
|
||||
.toc-title {{
|
||||
font-weight: 600;
|
||||
margin-bottom: 16px;
|
||||
color: var(--primary-color);
|
||||
}}
|
||||
|
||||
.toc-list {{
|
||||
list-style: none;
|
||||
}}
|
||||
|
||||
.toc-item {{
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}}
|
||||
|
||||
.toc-item:last-child {{
|
||||
border-bottom: none;
|
||||
}}
|
||||
|
||||
.toc-link {{
|
||||
color: var(--text-color);
|
||||
text-decoration: none;
|
||||
transition: color 0.2s ease;
|
||||
}}
|
||||
|
||||
.toc-link:hover {{
|
||||
color: var(--primary-color);
|
||||
}}
|
||||
|
||||
.reading-progress {{
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
background: rgba(37, 99, 235, 0.1);
|
||||
z-index: 1001;
|
||||
}}
|
||||
|
||||
.progress-bar {{
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--primary-color), #3b82f6);
|
||||
width: 0%;
|
||||
transition: width 0.1s ease;
|
||||
}}
|
||||
|
||||
@media (max-width: 768px) {{
|
||||
.container {{
|
||||
padding: 12px;
|
||||
}}
|
||||
|
||||
.language-switcher {{
|
||||
position: static;
|
||||
margin-bottom: 20px;
|
||||
}}
|
||||
|
||||
.title {{
|
||||
font-size: 1.5rem;
|
||||
}}
|
||||
|
||||
.metadata {{
|
||||
grid-template-columns: 1fr;
|
||||
}}
|
||||
}}
|
||||
|
||||
/* 다크 모드 지원 */
|
||||
@media (prefers-color-scheme: dark) {{
|
||||
:root {{
|
||||
--background-color: #0f172a;
|
||||
--text-color: #f1f5f9;
|
||||
--border-color: #334155;
|
||||
}}
|
||||
|
||||
.header, .content-section, .language-switcher {{
|
||||
background: #1e293b;
|
||||
}}
|
||||
|
||||
.lang-btn {{
|
||||
background: #1e293b;
|
||||
color: var(--text-color);
|
||||
}}
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="reading-progress">
|
||||
<div class="progress-bar" id="progressBar"></div>
|
||||
</div>
|
||||
|
||||
<div class="language-switcher">
|
||||
<div class="language-buttons">
|
||||
{language_buttons}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1 class="title">{title}</h1>
|
||||
<div class="metadata">
|
||||
{metadata}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{toc_section}
|
||||
|
||||
{summary_section}
|
||||
|
||||
<div class="content-section">
|
||||
<div class="section-header">
|
||||
📖 본문 내용
|
||||
</div>
|
||||
<div class="section-content">
|
||||
{content_sections}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// 언어 전환 기능
|
||||
function switchLanguage(lang) {{
|
||||
// 모든 언어 콘텐츠 숨기기
|
||||
document.querySelectorAll('.language-content').forEach(el => {{
|
||||
el.classList.remove('active');
|
||||
}});
|
||||
|
||||
// 선택된 언어 콘텐츠 표시
|
||||
document.querySelectorAll(`.language-content[data-lang="${{lang}}"]`).forEach(el => {{
|
||||
el.classList.add('active');
|
||||
}});
|
||||
|
||||
// 버튼 상태 업데이트
|
||||
document.querySelectorAll('.lang-btn').forEach(btn => {{
|
||||
btn.classList.remove('active');
|
||||
}});
|
||||
document.querySelector(`.lang-btn[data-lang="${{lang}}"]`).classList.add('active');
|
||||
|
||||
// 로컬 스토리지에 언어 설정 저장
|
||||
localStorage.setItem('selectedLanguage', lang);
|
||||
}}
|
||||
|
||||
// 읽기 진행률 표시
|
||||
function updateProgress() {{
|
||||
const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
|
||||
const scrollHeight = document.documentElement.scrollHeight - window.innerHeight;
|
||||
const progress = (scrollTop / scrollHeight) * 100;
|
||||
document.getElementById('progressBar').style.width = progress + '%';
|
||||
}}
|
||||
|
||||
// 초기화
|
||||
document.addEventListener('DOMContentLoaded', function() {{
|
||||
// 저장된 언어 설정 복원
|
||||
const savedLang = localStorage.getItem('selectedLanguage') || '{default_lang}';
|
||||
switchLanguage(savedLang);
|
||||
|
||||
// 스크롤 이벤트 리스너
|
||||
window.addEventListener('scroll', updateProgress);
|
||||
|
||||
// 언어 버튼 이벤트 리스너
|
||||
document.querySelectorAll('.lang-btn').forEach(btn => {{
|
||||
btn.addEventListener('click', () => {{
|
||||
switchLanguage(btn.dataset.lang);
|
||||
}});
|
||||
}});
|
||||
}});
|
||||
|
||||
// 키보드 단축키 (1, 2, 3으로 언어 전환)
|
||||
document.addEventListener('keydown', function(e) {{
|
||||
const langButtons = document.querySelectorAll('.lang-btn');
|
||||
const key = parseInt(e.key);
|
||||
if (key >= 1 && key <= langButtons.length) {{
|
||||
const targetLang = langButtons[key - 1].dataset.lang;
|
||||
switchLanguage(targetLang);
|
||||
}}
|
||||
}});
|
||||
</script>
|
||||
</body>
|
||||
</html>""",
|
||||
|
||||
"language_button": """<button class="lang-btn" data-lang="{lang_code}">{lang_name}</button>""",
|
||||
|
||||
"metadata_item": """<div class="metadata-item">
|
||||
<span class="metadata-icon">{icon}</span>
|
||||
<span>{label}: {value}</span>
|
||||
</div>""",
|
||||
|
||||
"content_section": """<div class="language-content" data-lang="{lang_code}">
|
||||
<div class="text-content">{content}</div>
|
||||
</div>""",
|
||||
|
||||
"summary_section": """<div class="content-section">
|
||||
<div class="section-header">
|
||||
📋 요약
|
||||
</div>
|
||||
<div class="section-content">
|
||||
<div class="summary-box">
|
||||
<div class="summary-title">문서 요약</div>
|
||||
{summary_content}
|
||||
</div>
|
||||
</div>
|
||||
</div>""",
|
||||
|
||||
"toc_section": """<div class="toc">
|
||||
<div class="toc-title">📑 목차</div>
|
||||
<ul class="toc-list">
|
||||
{toc_items}
|
||||
</ul>
|
||||
</div>"""
|
||||
}
|
||||
|
||||
def _generate_toc(self, content: str) -> List[Dict]:
|
||||
"""목차 자동 생성"""
|
||||
# 간단한 헤딩 감지 (대문자로 시작하는 짧은 줄들)
|
||||
lines = content.split('\n')
|
||||
toc_items = []
|
||||
|
||||
for i, line in enumerate(lines):
|
||||
line = line.strip()
|
||||
# 헤딩 감지 조건
|
||||
if (len(line) < 100 and
|
||||
len(line) > 5 and
|
||||
(line.isupper() or
|
||||
line.startswith(('Chapter', '장', '제', '1.', '2.', '3.', '4.', '5.')))):
|
||||
|
||||
toc_items.append({
|
||||
"title": line,
|
||||
"anchor": f"section-{i}",
|
||||
"line_number": i
|
||||
})
|
||||
|
||||
return toc_items[:10] # 최대 10개만
|
||||
|
||||
def generate_multilingual_html(self,
|
||||
title: str,
|
||||
contents: Dict[str, str], # {lang_code: content}
|
||||
summary: str = "",
|
||||
metadata: Dict = None,
|
||||
output_path: str = "output.html") -> str:
|
||||
"""다국어 HTML 생성"""
|
||||
|
||||
# 언어 정보 매핑
|
||||
lang_info = {
|
||||
"korean": {"name": "한국어", "code": "ko", "icon": "🇰🇷"},
|
||||
"english": {"name": "English", "code": "en", "icon": "🇺🇸"},
|
||||
"japanese": {"name": "日本語", "code": "ja", "icon": "🇯🇵"}
|
||||
}
|
||||
|
||||
# 기본 메타데이터
|
||||
if metadata is None:
|
||||
metadata = {}
|
||||
|
||||
default_metadata = {
|
||||
"처리_시간": datetime.now().strftime("%Y-%m-%d %H:%M"),
|
||||
"언어_수": len(contents),
|
||||
"총_문자수": sum(len(content) for content in contents.values()),
|
||||
"생성_도구": "NLLB 번역 시스템"
|
||||
}
|
||||
default_metadata.update(metadata)
|
||||
|
||||
# 언어 버튼 생성
|
||||
language_buttons = []
|
||||
for lang_key, content in contents.items():
|
||||
lang_data = lang_info.get(lang_key, {"name": lang_key.title(), "code": lang_key[:2], "icon": "🌐"})
|
||||
button_html = self.templates["language_button"].format(
|
||||
lang_code=lang_data["code"],
|
||||
lang_name=f"{lang_data['icon']} {lang_data['name']}"
|
||||
)
|
||||
language_buttons.append(button_html)
|
||||
|
||||
# 메타데이터 생성
|
||||
metadata_items = []
|
||||
metadata_icons = {
|
||||
"처리_시간": "⏰",
|
||||
"언어_수": "🌍",
|
||||
"총_문자수": "📝",
|
||||
"생성_도구": "⚙️",
|
||||
"원본_언어": "🔤",
|
||||
"파일_크기": "📊"
|
||||
}
|
||||
|
||||
for key, value in default_metadata.items():
|
||||
icon = metadata_icons.get(key, "📄")
|
||||
label = key.replace("_", " ").title()
|
||||
|
||||
metadata_html = self.templates["metadata_item"].format(
|
||||
icon=icon,
|
||||
label=label,
|
||||
value=value
|
||||
)
|
||||
metadata_items.append(metadata_html)
|
||||
|
||||
# 콘텐츠 섹션 생성
|
||||
content_sections = []
|
||||
default_lang = list(contents.keys())[0]
|
||||
|
||||
for lang_key, content in contents.items():
|
||||
lang_data = lang_info.get(lang_key, {"code": lang_key[:2]})
|
||||
|
||||
# 내용을 문단별로 정리
|
||||
formatted_content = self._format_content(content)
|
||||
|
||||
section_html = self.templates["content_section"].format(
|
||||
lang_code=lang_data["code"],
|
||||
content=formatted_content
|
||||
)
|
||||
content_sections.append(section_html)
|
||||
|
||||
# 목차 생성 (첫 번째 언어 기준)
|
||||
first_content = list(contents.values())[0]
|
||||
toc_items = self._generate_toc(first_content)
|
||||
|
||||
toc_html = ""
|
||||
if toc_items:
|
||||
toc_item_htmls = []
|
||||
for item in toc_items:
|
||||
toc_item_htmls.append(f'<li class="toc-item"><a href="#{item["anchor"]}" class="toc-link">{item["title"]}</a></li>')
|
||||
|
||||
toc_html = self.templates["toc_section"].format(
|
||||
toc_items="\n".join(toc_item_htmls)
|
||||
)
|
||||
|
||||
# 요약 섹션
|
||||
summary_html = ""
|
||||
if summary:
|
||||
summary_content_sections = []
|
||||
for lang_key in contents.keys():
|
||||
lang_data = lang_info.get(lang_key, {"code": lang_key[:2]})
|
||||
if lang_key == "korean" or "korean" not in contents:
|
||||
summary_content = f'<div class="language-content active" data-lang="{lang_data["code"]}">{summary}</div>'
|
||||
else:
|
||||
summary_content = f'<div class="language-content" data-lang="{lang_data["code"]}">{summary}</div>'
|
||||
summary_content_sections.append(summary_content)
|
||||
|
||||
summary_html = self.templates["summary_section"].format(
|
||||
summary_content="\n".join(summary_content_sections)
|
||||
)
|
||||
|
||||
# 최종 HTML 생성
|
||||
default_lang_code = lang_info.get(default_lang, {"code": default_lang[:2]})["code"]
|
||||
|
||||
html_content = self.templates["base"].format(
|
||||
title=title,
|
||||
language_buttons="\n".join(language_buttons),
|
||||
metadata="\n".join(metadata_items),
|
||||
toc_section=toc_html,
|
||||
summary_section=summary_html,
|
||||
content_sections="\n".join(content_sections),
|
||||
default_lang=default_lang_code
|
||||
)
|
||||
|
||||
# 파일 저장
|
||||
output_file = Path(output_path)
|
||||
output_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with open(output_file, 'w', encoding='utf-8') as f:
|
||||
f.write(html_content)
|
||||
|
||||
print(f"다국어 HTML 생성 완료: {output_path}")
|
||||
return str(output_file)
|
||||
|
||||
def _format_content(self, content: str) -> str:
|
||||
"""내용 포맷팅"""
|
||||
# 기본 텍스트 정리
|
||||
content = content.strip()
|
||||
|
||||
# 문단 구분 개선
|
||||
content = re.sub(r'\n\s*\n\s*\n+', '\n\n', content)
|
||||
|
||||
# 특수 문자 이스케이프
|
||||
content = content.replace('<', '<').replace('>', '>')
|
||||
|
||||
return content
|
||||
|
||||
def main():
|
||||
"""HTML 생성기 테스트"""
|
||||
generator = MultilingualHTMLGenerator()
|
||||
|
||||
# 테스트 데이터
|
||||
test_contents = {
|
||||
"english": """Chapter 1: Introduction to Machine Learning
|
||||
|
||||
Machine learning represents one of the most transformative technologies of our time. This comprehensive guide explores the core concepts, methodologies, and applications that define this rapidly evolving field.
|
||||
|
||||
The power of machine learning lies in its ability to handle complex problems that would be difficult or impossible to solve using conventional programming methods.""",
|
||||
|
||||
"korean": """제1장: 기계학습 소개
|
||||
|
||||
기계학습은 우리 시대의 가장 혁신적인 기술 중 하나를 나타냅니다. 이 포괄적인 가이드는 빠르게 발전하는 이 분야를 정의하는 핵심 개념, 방법론 및 응용 분야를 탐구합니다.
|
||||
|
||||
기계학습의 힘은 기존 프로그래밍 방법으로는 해결하기 어렵거나 불가능한 복잡한 문제를 처리할 수 있는 능력에 있습니다."""
|
||||
}
|
||||
|
||||
test_summary = "이 문서는 기계학습의 기본 개념과 응용 분야에 대해 설명합니다. 기계학습이 현대 기술에서 차지하는 중요성과 복잡한 문제 해결 능력을 강조합니다."
|
||||
|
||||
test_metadata = {
|
||||
"원본_언어": "English",
|
||||
"파일_크기": "2.3 MB"
|
||||
}
|
||||
|
||||
# HTML 생성
|
||||
output_path = generator.generate_multilingual_html(
|
||||
title="기계학습 완전 가이드",
|
||||
contents=test_contents,
|
||||
summary=test_summary,
|
||||
metadata=test_metadata,
|
||||
output_path="output/test_multilingual.html"
|
||||
)
|
||||
|
||||
print(f"테스트 HTML 생성됨: {output_path}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
364
src/integrated_translation_system.py
Executable file
364
src/integrated_translation_system.py
Executable file
@@ -0,0 +1,364 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
완전한 PDF -> HTML 번역 시스템
|
||||
PDF OCR -> NLLB 번역 -> KoBART 요약 -> HTML 생성
|
||||
"""
|
||||
|
||||
import torch
|
||||
import time
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
|
||||
# 문서 처리
|
||||
try:
|
||||
import PyPDF2
|
||||
import pdfplumber
|
||||
from docx import Document
|
||||
except ImportError:
|
||||
print("문서 처리 라이브러리 설치 필요: pip install PyPDF2 pdfplumber python-docx")
|
||||
|
||||
# 번역 및 요약 모델
|
||||
from transformers import (
|
||||
AutoTokenizer, AutoModelForSeq2SeqLM,
|
||||
PreTrainedTokenizerFast, BartForConditionalGeneration
|
||||
)
|
||||
|
||||
@dataclass
|
||||
class TranslationResult:
|
||||
original_text: str
|
||||
translated_text: str
|
||||
summary: str
|
||||
processing_time: float
|
||||
metadata: Dict
|
||||
|
||||
class IntegratedTranslationSystem:
|
||||
def __init__(self):
|
||||
self.device = self._setup_device()
|
||||
self.models = {}
|
||||
self.tokenizers = {}
|
||||
self.config = self._load_config()
|
||||
|
||||
print(f"번역 시스템 초기화 (디바이스: {self.device})")
|
||||
|
||||
def _setup_device(self) -> torch.device:
|
||||
"""최적 디바이스 설정"""
|
||||
if torch.backends.mps.is_available():
|
||||
return torch.device("mps")
|
||||
elif torch.cuda.is_available():
|
||||
return torch.device("cuda")
|
||||
else:
|
||||
return torch.device("cpu")
|
||||
|
||||
def _load_config(self) -> Dict:
|
||||
"""설정 파일 로드"""
|
||||
config_path = Path("config/settings.json")
|
||||
if config_path.exists():
|
||||
with open(config_path, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
|
||||
# 기본 설정
|
||||
return {
|
||||
"translation": {
|
||||
"chunk_size": 500,
|
||||
"max_length": 512,
|
||||
"num_beams": 4,
|
||||
"batch_size": 4
|
||||
},
|
||||
"summarization": {
|
||||
"max_length": 150,
|
||||
"min_length": 30,
|
||||
"num_beams": 4
|
||||
}
|
||||
}
|
||||
|
||||
def load_models(self):
|
||||
"""모든 모델 로드"""
|
||||
print("모델 로딩 중...")
|
||||
|
||||
# 1. NLLB 번역 모델
|
||||
print(" NLLB 번역 모델...")
|
||||
try:
|
||||
self.tokenizers['nllb'] = AutoTokenizer.from_pretrained("facebook/nllb-200-3.3B")
|
||||
self.models['nllb'] = AutoModelForSeq2SeqLM.from_pretrained(
|
||||
"facebook/nllb-200-3.3B",
|
||||
torch_dtype=torch.float16,
|
||||
low_cpu_mem_usage=True
|
||||
).to(self.device)
|
||||
print(" NLLB 모델 로드 완료")
|
||||
except Exception as e:
|
||||
print(f" NLLB 모델 로드 실패: {e}")
|
||||
return False
|
||||
|
||||
# 2. KoBART 요약 모델
|
||||
print(" KoBART 요약 모델...")
|
||||
try:
|
||||
self.tokenizers['kobart'] = PreTrainedTokenizerFast.from_pretrained("gogamza/kobart-summarization")
|
||||
self.models['kobart'] = BartForConditionalGeneration.from_pretrained(
|
||||
"gogamza/kobart-summarization",
|
||||
torch_dtype=torch.float16,
|
||||
low_cpu_mem_usage=True
|
||||
).to(self.device)
|
||||
print(" KoBART 모델 로드 완료")
|
||||
except Exception as e:
|
||||
print(f" KoBART 모델 로드 실패: {e}")
|
||||
print(" 요약 없이 번역만 진행")
|
||||
self.models['kobart'] = None
|
||||
|
||||
print("모델 로딩 완료!")
|
||||
return True
|
||||
|
||||
def detect_language(self, text: str) -> str:
|
||||
"""언어 자동 감지"""
|
||||
# 간단한 휴리스틱 언어 감지
|
||||
korean_chars = len(re.findall(r'[가-힣]', text))
|
||||
japanese_chars = len(re.findall(r'[ひらがなカタカナ一-龯]', text))
|
||||
english_chars = len(re.findall(r'[a-zA-Z]', text))
|
||||
|
||||
total_chars = len(text.replace(' ', ''))
|
||||
|
||||
if total_chars == 0:
|
||||
return "unknown"
|
||||
|
||||
if korean_chars / total_chars > 0.3:
|
||||
return "korean"
|
||||
elif japanese_chars / total_chars > 0.1:
|
||||
return "japanese"
|
||||
elif english_chars / total_chars > 0.5:
|
||||
return "english"
|
||||
else:
|
||||
return "unknown"
|
||||
|
||||
def extract_text_from_pdf(self, pdf_path: str) -> str:
|
||||
"""PDF에서 텍스트 추출"""
|
||||
print(f"PDF 텍스트 추출: {pdf_path}")
|
||||
|
||||
text = ""
|
||||
try:
|
||||
# pdfplumber 우선 시도
|
||||
import pdfplumber
|
||||
with pdfplumber.open(pdf_path) as pdf:
|
||||
for page_num, page in enumerate(pdf.pages, 1):
|
||||
page_text = page.extract_text()
|
||||
if page_text:
|
||||
text += f"\n\n{page_text}"
|
||||
|
||||
print(f"PDF 텍스트 추출 완료: {len(text)}자")
|
||||
|
||||
except Exception as e:
|
||||
print(f"pdfplumber 실패: {e}")
|
||||
|
||||
# PyPDF2 백업
|
||||
try:
|
||||
import PyPDF2
|
||||
with open(pdf_path, 'rb') as file:
|
||||
pdf_reader = PyPDF2.PdfReader(file)
|
||||
for page in pdf_reader.pages:
|
||||
page_text = page.extract_text()
|
||||
if page_text:
|
||||
text += f"\n\n{page_text}"
|
||||
|
||||
print(f"PyPDF2로 텍스트 추출 완료: {len(text)}자")
|
||||
|
||||
except Exception as e2:
|
||||
print(f"PDF 텍스트 추출 완전 실패: {e2}")
|
||||
return ""
|
||||
|
||||
return self._clean_text(text)
|
||||
|
||||
def _clean_text(self, text: str) -> str:
|
||||
"""추출된 텍스트 정리"""
|
||||
# 과도한 공백 정리
|
||||
text = re.sub(r'\n\s*\n\s*\n', '\n\n', text)
|
||||
text = re.sub(r'[ \t]+', ' ', text)
|
||||
|
||||
# 페이지 번호 제거
|
||||
text = re.sub(r'\n\d+\n', '\n', text)
|
||||
|
||||
return text.strip()
|
||||
|
||||
def split_text_into_chunks(self, text: str, chunk_size: int = 500) -> List[str]:
|
||||
"""텍스트를 번역 가능한 청크로 분할"""
|
||||
sentences = re.split(r'[.!?]\s+', text)
|
||||
chunks = []
|
||||
current_chunk = ""
|
||||
|
||||
for sentence in sentences:
|
||||
test_chunk = current_chunk + " " + sentence if current_chunk else sentence
|
||||
|
||||
if len(test_chunk) > chunk_size and current_chunk:
|
||||
chunks.append(current_chunk.strip())
|
||||
current_chunk = sentence
|
||||
else:
|
||||
current_chunk = test_chunk
|
||||
|
||||
if current_chunk:
|
||||
chunks.append(current_chunk.strip())
|
||||
|
||||
print(f"텍스트 분할: {len(chunks)}개 청크")
|
||||
return chunks
|
||||
|
||||
def translate_text(self, text: str, src_lang: str = "english") -> str:
|
||||
"""NLLB로 텍스트 번역"""
|
||||
if src_lang == "korean":
|
||||
return text # 한국어는 번역하지 않음
|
||||
|
||||
# 언어 코드 매핑
|
||||
lang_map = {
|
||||
"english": "eng_Latn",
|
||||
"japanese": "jpn_Jpan",
|
||||
"korean": "kor_Hang"
|
||||
}
|
||||
|
||||
src_code = lang_map.get(src_lang, "eng_Latn")
|
||||
tgt_code = "kor_Hang"
|
||||
|
||||
tokenizer = self.tokenizers['nllb']
|
||||
model = self.models['nllb']
|
||||
|
||||
# 청크별 번역
|
||||
chunks = self.split_text_into_chunks(text, self.config["translation"]["chunk_size"])
|
||||
translated_chunks = []
|
||||
|
||||
print(f"번역 시작: {src_lang} -> 한국어")
|
||||
|
||||
for i, chunk in enumerate(chunks):
|
||||
print(f" 청크 {i+1}/{len(chunks)} 번역 중...")
|
||||
|
||||
inputs = tokenizer(chunk, return_tensors="pt", padding=True, truncation=True).to(self.device)
|
||||
|
||||
with torch.no_grad():
|
||||
translated_tokens = model.generate(
|
||||
**inputs,
|
||||
forced_bos_token_id=tokenizer.convert_tokens_to_ids(tgt_code),
|
||||
max_length=self.config["translation"]["max_length"],
|
||||
num_beams=self.config["translation"]["num_beams"],
|
||||
early_stopping=True
|
||||
)
|
||||
|
||||
translated_chunk = tokenizer.decode(translated_tokens[0], skip_special_tokens=True)
|
||||
translated_chunks.append(translated_chunk)
|
||||
|
||||
result = "\n\n".join(translated_chunks)
|
||||
print(f"번역 완료: {len(result)}자")
|
||||
return result
|
||||
|
||||
def summarize_text(self, text: str) -> str:
|
||||
"""KoBART로 한국어 텍스트 요약"""
|
||||
if self.models['kobart'] is None:
|
||||
print("요약 모델 없음, 첫 300자 반환")
|
||||
return text[:300] + "..." if len(text) > 300 else text
|
||||
|
||||
print("텍스트 요약 중...")
|
||||
|
||||
tokenizer = self.tokenizers['kobart']
|
||||
model = self.models['kobart']
|
||||
|
||||
inputs = tokenizer(
|
||||
text,
|
||||
return_tensors="pt",
|
||||
max_length=1024,
|
||||
truncation=True,
|
||||
padding=True,
|
||||
return_token_type_ids=False
|
||||
).to(self.device)
|
||||
|
||||
with torch.no_grad():
|
||||
summary_ids = model.generate(
|
||||
input_ids=inputs['input_ids'],
|
||||
attention_mask=inputs['attention_mask'],
|
||||
max_length=self.config["summarization"]["max_length"],
|
||||
min_length=self.config["summarization"]["min_length"],
|
||||
num_beams=self.config["summarization"]["num_beams"],
|
||||
early_stopping=True,
|
||||
no_repeat_ngram_size=2,
|
||||
length_penalty=1.2
|
||||
)
|
||||
|
||||
summary = tokenizer.decode(summary_ids[0], skip_special_tokens=True)
|
||||
print(f"요약 완료: {len(summary)}자")
|
||||
return summary
|
||||
|
||||
def process_document(self, input_path: str, output_dir: str = "output") -> TranslationResult:
|
||||
"""전체 문서 처리 파이프라인"""
|
||||
start_time = time.time()
|
||||
|
||||
print(f"문서 처리 시작: {input_path}")
|
||||
|
||||
# 1. 텍스트 추출
|
||||
if input_path.lower().endswith('.pdf'):
|
||||
original_text = self.extract_text_from_pdf(input_path)
|
||||
else:
|
||||
with open(input_path, 'r', encoding='utf-8') as f:
|
||||
original_text = f.read()
|
||||
|
||||
if not original_text:
|
||||
raise ValueError("텍스트 추출 실패")
|
||||
|
||||
# 2. 언어 감지
|
||||
detected_lang = self.detect_language(original_text)
|
||||
print(f"감지된 언어: {detected_lang}")
|
||||
|
||||
# 3. 번역
|
||||
if detected_lang == "korean":
|
||||
translated_text = original_text
|
||||
print("한국어 문서, 번역 생략")
|
||||
else:
|
||||
translated_text = self.translate_text(original_text, detected_lang)
|
||||
|
||||
# 4. 요약
|
||||
summary = self.summarize_text(translated_text)
|
||||
|
||||
# 5. 결과 저장
|
||||
output_path = Path(output_dir)
|
||||
output_path.mkdir(exist_ok=True)
|
||||
|
||||
base_name = Path(input_path).stem
|
||||
|
||||
# 텍스트 파일 저장
|
||||
with open(output_path / f"{base_name}_translated.txt", 'w', encoding='utf-8') as f:
|
||||
f.write(translated_text)
|
||||
|
||||
with open(output_path / f"{base_name}_summary.txt", 'w', encoding='utf-8') as f:
|
||||
f.write(summary)
|
||||
|
||||
processing_time = time.time() - start_time
|
||||
|
||||
result = TranslationResult(
|
||||
original_text=original_text,
|
||||
translated_text=translated_text,
|
||||
summary=summary,
|
||||
processing_time=processing_time,
|
||||
metadata={
|
||||
"input_file": input_path,
|
||||
"detected_language": detected_lang,
|
||||
"original_chars": len(original_text),
|
||||
"translated_chars": len(translated_text),
|
||||
"summary_chars": len(summary),
|
||||
"compression_ratio": len(summary) / len(translated_text) * 100 if translated_text else 0
|
||||
}
|
||||
)
|
||||
|
||||
print(f"문서 처리 완료! ({processing_time/60:.1f}분 소요)")
|
||||
return result
|
||||
|
||||
def main():
|
||||
"""메인 실행 함수"""
|
||||
system = IntegratedTranslationSystem()
|
||||
|
||||
if not system.load_models():
|
||||
print("모델 로딩 실패")
|
||||
return None
|
||||
|
||||
print("\n" + "="*60)
|
||||
print("통합 번역 시스템 준비 완료!")
|
||||
print("사용법:")
|
||||
print(" result = system.process_document('input.pdf')")
|
||||
print("="*60)
|
||||
|
||||
return system
|
||||
|
||||
if __name__ == "__main__":
|
||||
system = main()
|
||||
237
src/nas_mount_setup.py
Executable file
237
src/nas_mount_setup.py
Executable file
@@ -0,0 +1,237 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
NAS 자동 마운트 및 연결 설정
|
||||
DS1525+ (192.168.1.227) → Mac Mini (192.168.1.122)
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
import os
|
||||
from pathlib import Path
|
||||
import time
|
||||
|
||||
class NASMountManager:
|
||||
def __init__(self):
|
||||
self.nas_ip = "192.168.1.227"
|
||||
self.nas_share = "Media" # 또는 실제 공유 폴더명
|
||||
self.mount_point = Path("/Volumes/DS1525+")
|
||||
self.smb_url = f"smb://{self.nas_ip}/{self.nas_share}"
|
||||
|
||||
def check_nas_connection(self):
|
||||
"""NAS 연결 상태 확인"""
|
||||
try:
|
||||
# ping으로 NAS 접근 가능한지 확인
|
||||
result = subprocess.run(
|
||||
["ping", "-c", "3", self.nas_ip],
|
||||
capture_output=True,
|
||||
timeout=10
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
print(f"✅ NAS 연결 가능: {self.nas_ip}")
|
||||
return True
|
||||
else:
|
||||
print(f"❌ NAS 연결 불가: {self.nas_ip}")
|
||||
return False
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
print(f"⏰ NAS 연결 타임아웃: {self.nas_ip}")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"❌ 연결 확인 오류: {e}")
|
||||
return False
|
||||
|
||||
def check_mount_status(self):
|
||||
"""마운트 상태 확인"""
|
||||
if self.mount_point.exists():
|
||||
# 실제 마운트되어 있는지 확인
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["mount"],
|
||||
capture_output=True,
|
||||
text=True
|
||||
)
|
||||
|
||||
if str(self.mount_point) in result.stdout:
|
||||
print(f"✅ NAS 이미 마운트됨: {self.mount_point}")
|
||||
return True
|
||||
else:
|
||||
print(f"📁 마운트 포인트 존재하지만 연결 안됨: {self.mount_point}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 마운트 상태 확인 실패: {e}")
|
||||
return False
|
||||
else:
|
||||
print(f"📁 마운트 포인트 없음: {self.mount_point}")
|
||||
return False
|
||||
|
||||
def mount_nas(self, username=None, password=None):
|
||||
"""NAS 마운트"""
|
||||
|
||||
if self.check_mount_status():
|
||||
return True
|
||||
|
||||
if not self.check_nas_connection():
|
||||
return False
|
||||
|
||||
try:
|
||||
# 마운트 포인트 생성
|
||||
self.mount_point.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# SMB 마운트 시도
|
||||
if username and password:
|
||||
# 인증 정보가 있는 경우
|
||||
mount_cmd = [
|
||||
"mount", "-t", "smbfs",
|
||||
f"//{username}:{password}@{self.nas_ip}/{self.nas_share}",
|
||||
str(self.mount_point)
|
||||
]
|
||||
else:
|
||||
# 게스트 접근 시도
|
||||
mount_cmd = [
|
||||
"mount", "-t", "smbfs",
|
||||
f"//{self.nas_ip}/{self.nas_share}",
|
||||
str(self.mount_point)
|
||||
]
|
||||
|
||||
print(f"🔄 NAS 마운트 시도: {self.smb_url}")
|
||||
result = subprocess.run(mount_cmd, capture_output=True, text=True)
|
||||
|
||||
if result.returncode == 0:
|
||||
print(f"✅ NAS 마운트 성공: {self.mount_point}")
|
||||
return True
|
||||
else:
|
||||
print(f"❌ NAS 마운트 실패: {result.stderr}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 마운트 프로세스 오류: {e}")
|
||||
return False
|
||||
|
||||
def unmount_nas(self):
|
||||
"""NAS 언마운트"""
|
||||
try:
|
||||
if self.check_mount_status():
|
||||
result = subprocess.run(
|
||||
["umount", str(self.mount_point)],
|
||||
capture_output=True,
|
||||
text=True
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
print(f"✅ NAS 언마운트 완료: {self.mount_point}")
|
||||
return True
|
||||
else:
|
||||
print(f"❌ 언마운트 실패: {result.stderr}")
|
||||
return False
|
||||
else:
|
||||
print("ℹ️ NAS가 마운트되어 있지 않음")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 언마운트 오류: {e}")
|
||||
return False
|
||||
|
||||
def create_document_upload_structure(self):
|
||||
"""Document-upload 폴더 구조 생성"""
|
||||
if not self.check_mount_status():
|
||||
print("❌ NAS가 마운트되지 않음")
|
||||
return False
|
||||
|
||||
base_path = self.mount_point / "Document-upload"
|
||||
|
||||
try:
|
||||
# 기본 구조 생성
|
||||
folders = [
|
||||
"originals",
|
||||
"originals/2025-01/pdfs",
|
||||
"originals/2025-01/docs",
|
||||
"originals/2025-01/txts",
|
||||
"translated",
|
||||
"translated/2025-01/english-to-korean",
|
||||
"translated/2025-01/japanese-to-korean",
|
||||
"translated/2025-01/korean-only",
|
||||
"static-hosting/docs",
|
||||
"static-hosting/assets",
|
||||
"static-hosting/index",
|
||||
"metadata/processing-logs"
|
||||
]
|
||||
|
||||
for folder in folders:
|
||||
folder_path = base_path / folder
|
||||
folder_path.mkdir(parents=True, exist_ok=True)
|
||||
print(f"📁 폴더 생성: {folder}")
|
||||
|
||||
# 기본 README 파일 생성
|
||||
readme_content = """# Document Upload System
|
||||
|
||||
이 폴더는 AI 번역 시스템에서 자동으로 관리됩니다.
|
||||
|
||||
## 폴더 구조
|
||||
|
||||
- originals/: 업로드된 원본 파일들 (월별/타입별 정리)
|
||||
- translated/: 번역된 HTML 파일들 (월별/언어별 정리)
|
||||
- static-hosting/: 웹 호스팅용 파일들
|
||||
- metadata/: 처리 로그 및 인덱스 정보
|
||||
|
||||
## 자동 관리
|
||||
|
||||
- 파일은 월별로 자동 정리됩니다
|
||||
- 언어 감지에 따라 적절한 폴더에 저장됩니다
|
||||
- 메타데이터는 자동으로 기록됩니다
|
||||
"""
|
||||
|
||||
readme_path = base_path / "README.md"
|
||||
with open(readme_path, 'w', encoding='utf-8') as f:
|
||||
f.write(readme_content)
|
||||
|
||||
print(f"✅ Document-upload 구조 생성 완료: {base_path}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 폴더 구조 생성 실패: {e}")
|
||||
return False
|
||||
|
||||
def main():
|
||||
"""NAS 설정 메인 함수"""
|
||||
print("🚀 NAS 마운트 및 설정 시작")
|
||||
print("=" * 50)
|
||||
|
||||
mount_manager = NASMountManager()
|
||||
|
||||
# 1. NAS 연결 확인
|
||||
if not mount_manager.check_nas_connection():
|
||||
print("\n❌ NAS에 연결할 수 없습니다.")
|
||||
print("확인사항:")
|
||||
print("- NAS 전원이 켜져 있는지")
|
||||
print("- 네트워크 연결 상태")
|
||||
print("- IP 주소: 192.168.1.227")
|
||||
return False
|
||||
|
||||
# 2. 마운트 시도
|
||||
if not mount_manager.check_mount_status():
|
||||
print("\n🔄 NAS 마운트 시도...")
|
||||
|
||||
# 우선 게스트 접근 시도
|
||||
if not mount_manager.mount_nas():
|
||||
print("\n🔐 인증이 필요할 수 있습니다.")
|
||||
print("Finder에서 수동으로 연결하거나:")
|
||||
print("1. Finder → 이동 → 서버에 연결")
|
||||
print("2. smb://192.168.1.227 입력")
|
||||
print("3. 인증 정보 입력")
|
||||
|
||||
return False
|
||||
|
||||
# 3. 폴더 구조 생성
|
||||
print("\n📁 Document-upload 폴더 구조 생성...")
|
||||
if mount_manager.create_document_upload_structure():
|
||||
print("\n🎉 NAS 설정 완료!")
|
||||
print(f"📁 마운트 위치: {mount_manager.mount_point}")
|
||||
print(f"📁 문서 저장 위치: {mount_manager.mount_point}/Document-upload")
|
||||
return True
|
||||
else:
|
||||
print("\n❌ 폴더 구조 생성 실패")
|
||||
return False
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
96
src/test_nllb_fixed.py
Executable file
96
src/test_nllb_fixed.py
Executable file
@@ -0,0 +1,96 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
NLLB 모델 테스트 (수정된 버전)
|
||||
"""
|
||||
|
||||
import torch
|
||||
import time
|
||||
from transformers import AutoTokenizer, AutoModelForSeq2SeqLM
|
||||
|
||||
def test_nllb_fixed():
|
||||
print("🧪 NLLB 모델 테스트 (수정된 버전)")
|
||||
|
||||
model_name = "facebook/nllb-200-3.3B"
|
||||
|
||||
# 모델 로드
|
||||
print("📥 모델 로딩 중...")
|
||||
tokenizer = AutoTokenizer.from_pretrained(model_name, cache_dir="models/nllb-200-3.3B")
|
||||
model = AutoModelForSeq2SeqLM.from_pretrained(
|
||||
model_name,
|
||||
cache_dir="models/nllb-200-3.3B",
|
||||
torch_dtype=torch.float16,
|
||||
low_cpu_mem_usage=True
|
||||
)
|
||||
|
||||
# Apple Silicon 최적화
|
||||
if torch.backends.mps.is_available():
|
||||
device = torch.device("mps")
|
||||
model = model.to(device)
|
||||
print("🚀 Apple Silicon MPS 가속 사용")
|
||||
else:
|
||||
device = torch.device("cpu")
|
||||
print("💻 CPU 모드 사용")
|
||||
|
||||
# NLLB 언어 코드 (수정된 방식)
|
||||
def get_lang_id(tokenizer, lang_code):
|
||||
"""언어 코드를 토큰 ID로 변환"""
|
||||
return tokenizer.convert_tokens_to_ids(lang_code)
|
||||
|
||||
def translate_text(text, src_lang, tgt_lang, description):
|
||||
print(f"\n📝 {description}:")
|
||||
print(f"원문: {text}")
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
# 입력 텍스트 토큰화
|
||||
inputs = tokenizer(text, return_tensors="pt", padding=True).to(device)
|
||||
|
||||
# 번역 생성 (수정된 방식)
|
||||
with torch.no_grad():
|
||||
generated_tokens = model.generate(
|
||||
**inputs,
|
||||
forced_bos_token_id=get_lang_id(tokenizer, tgt_lang),
|
||||
max_length=200,
|
||||
num_beams=4,
|
||||
early_stopping=True,
|
||||
do_sample=False,
|
||||
pad_token_id=tokenizer.pad_token_id
|
||||
)
|
||||
|
||||
# 결과 디코딩
|
||||
result = tokenizer.batch_decode(generated_tokens, skip_special_tokens=True)[0]
|
||||
|
||||
translation_time = time.time() - start_time
|
||||
print(f"번역: {result}")
|
||||
print(f"소요 시간: {translation_time:.2f}초")
|
||||
|
||||
return result, translation_time
|
||||
|
||||
print("\n" + "="*60)
|
||||
|
||||
try:
|
||||
# 1. 영어 → 한국어
|
||||
en_text = "Artificial intelligence is transforming the way we work and live."
|
||||
translate_text(en_text, "eng_Latn", "kor_Hang", "영어 → 한국어 번역")
|
||||
|
||||
# 2. 일본어 → 한국어
|
||||
ja_text = "人工知能は私たちの働き方と生活を変革しています。"
|
||||
translate_text(ja_text, "jpn_Jpan", "kor_Hang", "일본어 → 한국어 번역")
|
||||
|
||||
# 3. 기술 문서 테스트
|
||||
tech_text = "Machine learning algorithms require large datasets for training and validation."
|
||||
translate_text(tech_text, "eng_Latn", "kor_Hang", "기술 문서 번역")
|
||||
|
||||
print(f"\n✅ 모든 테스트 성공!")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 테스트 중 오류: {e}")
|
||||
return False
|
||||
|
||||
if __name__ == "__main__":
|
||||
if test_nllb_fixed():
|
||||
print("\n🎉 NLLB 모델 테스트 완료!")
|
||||
print("📝 다음 단계: KoBART 요약 모델 설치")
|
||||
else:
|
||||
print("\n❌ 테스트 실패")
|
||||
103
src/test_summarizer_fixed.py
Executable file
103
src/test_summarizer_fixed.py
Executable file
@@ -0,0 +1,103 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
요약 모델 테스트 (토큰 오류 수정)
|
||||
"""
|
||||
|
||||
import torch
|
||||
import time
|
||||
from transformers import PreTrainedTokenizerFast, BartForConditionalGeneration
|
||||
|
||||
def test_summarizer_fixed():
|
||||
print("🧪 한국어 요약 모델 테스트 (수정된 버전)")
|
||||
|
||||
model_name = "gogamza/kobart-summarization"
|
||||
|
||||
try:
|
||||
# 모델 로드
|
||||
print("📥 모델 로딩 중...")
|
||||
tokenizer = PreTrainedTokenizerFast.from_pretrained(model_name)
|
||||
model = BartForConditionalGeneration.from_pretrained(
|
||||
model_name,
|
||||
torch_dtype=torch.float16,
|
||||
low_cpu_mem_usage=True
|
||||
)
|
||||
|
||||
# Apple Silicon 최적화
|
||||
if torch.backends.mps.is_available():
|
||||
device = torch.device("mps")
|
||||
model = model.to(device)
|
||||
print("🚀 Apple Silicon MPS 가속 사용")
|
||||
else:
|
||||
device = torch.device("cpu")
|
||||
print("💻 CPU 모드 사용")
|
||||
|
||||
def summarize_text_fixed(text):
|
||||
print(f"\n📝 요약 테스트:")
|
||||
print(f"원문 ({len(text)}자):")
|
||||
print(f"{text[:150]}...")
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
# 토큰화 (token_type_ids 제거)
|
||||
inputs = tokenizer(
|
||||
text,
|
||||
return_tensors="pt",
|
||||
max_length=1024,
|
||||
truncation=True,
|
||||
padding=True,
|
||||
return_token_type_ids=False # 이 부분이 핵심!
|
||||
).to(device)
|
||||
|
||||
# 요약 생성
|
||||
with torch.no_grad():
|
||||
summary_ids = model.generate(
|
||||
input_ids=inputs['input_ids'],
|
||||
attention_mask=inputs['attention_mask'],
|
||||
max_length=150,
|
||||
min_length=30,
|
||||
num_beams=4,
|
||||
early_stopping=True,
|
||||
no_repeat_ngram_size=2,
|
||||
length_penalty=1.2,
|
||||
do_sample=False
|
||||
)
|
||||
|
||||
# 결과 디코딩
|
||||
summary = tokenizer.decode(summary_ids[0], skip_special_tokens=True)
|
||||
|
||||
process_time = time.time() - start_time
|
||||
print(f"\n📋 요약 결과 ({len(summary)}자):")
|
||||
print(f"{summary}")
|
||||
print(f"⏱️ 처리 시간: {process_time:.2f}초")
|
||||
print(f"📊 압축률: {len(summary)/len(text)*100:.1f}%")
|
||||
|
||||
return summary
|
||||
|
||||
# 테스트 실행
|
||||
test_text = """
|
||||
인공지능과 기계학습 기술이 급속도로 발전하면서 우리의 일상생활과 업무 환경에 혁신적인 변화를 가져오고 있습니다.
|
||||
특히 자연어 처리 분야에서는 번역, 요약, 대화형 AI 등의 기술이 실용적인 수준에 도달하여 다양한 서비스에 적용되고 있습니다.
|
||||
기계학습 알고리즘은 대량의 텍스트 데이터를 학습하여 언어의 패턴과 의미를 이해하고, 이를 바탕으로 인간과 유사한 수준의
|
||||
언어 처리 능력을 보여주고 있습니다. 딥러닝 기술의 발전으로 번역의 정확도가 크게 향상되었으며, 실시간 번역 서비스도
|
||||
일상적으로 사용할 수 있게 되었습니다.
|
||||
"""
|
||||
|
||||
summarize_text_fixed(test_text.strip())
|
||||
|
||||
print(f"\n✅ 요약 모델 테스트 성공!")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 테스트 실패: {e}")
|
||||
return False
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("🚀 한국어 요약 모델 테스트 (수정)")
|
||||
print("="*50)
|
||||
|
||||
if test_summarizer_fixed():
|
||||
print("\n🎉 요약 모델 정상 작동!")
|
||||
print("📝 다음 단계: 통합 번역 시스템 구축")
|
||||
else:
|
||||
print("\n❌ 여전히 문제 있음")
|
||||
print("📝 요약 없이 번역만으로 진행 고려")
|
||||
468
templates/index.html
Normal file
468
templates/index.html
Normal file
@@ -0,0 +1,468 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>AI 번역 시스템</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
background: white;
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 20px 40px rgba(0,0,0,0.1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.header {
|
||||
background: linear-gradient(135deg, #2563eb, #3b82f6);
|
||||
color: white;
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.header p {
|
||||
font-size: 1.1rem;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.nav-menu {
|
||||
background: #f8fafc;
|
||||
padding: 20px 40px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
display: flex;
|
||||
gap: 30px;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
color: #374151;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
transition: color 0.3s ease;
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.nav-link.active {
|
||||
color: #2563eb;
|
||||
border-bottom: 2px solid #2563eb;
|
||||
padding-bottom: 5px;
|
||||
}
|
||||
|
||||
.status-bar {
|
||||
background: #f0f9ff;
|
||||
padding: 15px 40px;
|
||||
border-bottom: 1px solid #bae6fd;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.status-good { color: #065f46; }
|
||||
.status-bad { color: #dc2626; }
|
||||
.status-warning { color: #d97706; }
|
||||
|
||||
.main-content {
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
.upload-section {
|
||||
text-align: center;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.file-drop-zone {
|
||||
border: 3px dashed #e5e7eb;
|
||||
border-radius: 16px;
|
||||
padding: 60px 20px;
|
||||
margin: 20px 0;
|
||||
transition: all 0.3s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.file-drop-zone:hover {
|
||||
border-color: #2563eb;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.file-drop-zone.drag-over {
|
||||
border-color: #2563eb;
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
.upload-icon {
|
||||
font-size: 4rem;
|
||||
color: #9ca3af;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.upload-text {
|
||||
font-size: 1.2rem;
|
||||
color: #374151;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.upload-hint {
|
||||
color: #6b7280;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.file-input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.upload-btn {
|
||||
background: #2563eb;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 12px 24px;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
margin-top: 20px;
|
||||
transition: background 0.3s ease;
|
||||
}
|
||||
|
||||
.upload-btn:hover {
|
||||
background: #1d4ed8;
|
||||
}
|
||||
|
||||
.upload-btn:disabled {
|
||||
background: #9ca3af;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.progress-section {
|
||||
display: none;
|
||||
background: #f8fafc;
|
||||
border-radius: 12px;
|
||||
padding: 30px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.progress-title {
|
||||
font-weight: 600;
|
||||
margin-bottom: 15px;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
width: 100%;
|
||||
height: 12px;
|
||||
background: #e5e7eb;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #2563eb, #3b82f6);
|
||||
width: 0%;
|
||||
transition: width 0.5s ease;
|
||||
}
|
||||
|
||||
.progress-text {
|
||||
font-size: 0.9rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.result-section {
|
||||
display: none;
|
||||
background: #f0f9ff;
|
||||
border: 1px solid #0ea5e9;
|
||||
border-radius: 12px;
|
||||
padding: 30px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.result-title {
|
||||
color: #0369a1;
|
||||
font-weight: 600;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.result-actions {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 10px 20px;
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
transition: all 0.3s ease;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #2563eb;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #1d4ed8;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #f8fafc;
|
||||
color: #374151;
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #f1f5f9;
|
||||
}
|
||||
|
||||
.error-section {
|
||||
display: none;
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
margin: 20px 0;
|
||||
color: #dc2626;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>🤖 AI 번역 시스템</h1>
|
||||
<p>PDF/문서를 다국어 HTML로 자동 변환</p>
|
||||
</div>
|
||||
|
||||
<div class="nav-menu">
|
||||
<div class="nav-links">
|
||||
<a href="/" class="nav-link active">📤 업로드</a>
|
||||
<a href="/library" class="nav-link">📚 라이브러리</a>
|
||||
<a href="/system-status" class="nav-link">📊 시스템</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="status-bar">
|
||||
<div>
|
||||
{% if nas_status.status == "connected" %}
|
||||
<span class="status-good">✅ NAS 연결됨 (DS1525+)</span>
|
||||
{% else %}
|
||||
<span class="status-bad">❌ NAS 연결 실패: {{ nas_status.error }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div>
|
||||
<span class="status-good">🚀 Mac Mini Ready</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="main-content">
|
||||
{% if nas_status.status != "connected" %}
|
||||
<div class="error-section" style="display: block;">
|
||||
<h3>NAS 연결 필요</h3>
|
||||
<p>{{ nas_status.error }}</p>
|
||||
<p><strong>해결 방법:</strong></p>
|
||||
<ul style="margin-left: 20px; margin-top: 10px;">
|
||||
<li>Finder → 이동 → 서버에 연결</li>
|
||||
<li>smb://192.168.1.227 입력</li>
|
||||
<li>DS1525+ 연결 후 페이지 새로고침</li>
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="upload-section">
|
||||
<div class="file-drop-zone" id="dropZone">
|
||||
<div class="upload-icon">📁</div>
|
||||
<div class="upload-text">파일을 드래그하거나 클릭하여 업로드</div>
|
||||
<div class="upload-hint">PDF, TXT, DOCX 파일 지원 (최대 100MB)</div>
|
||||
<input type="file" id="fileInput" class="file-input" accept=".pdf,.txt,.docx,.doc">
|
||||
<button type="button" class="upload-btn" id="uploadBtn"
|
||||
{% if nas_status.status != "connected" %}disabled{% endif %}
|
||||
onclick="document.getElementById('fileInput').click()">
|
||||
{% if nas_status.status == "connected" %}
|
||||
파일 선택
|
||||
{% else %}
|
||||
NAS 연결 필요
|
||||
{% endif %}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="progress-section" id="progressSection">
|
||||
<div class="progress-title">처리 진행 상황</div>
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" id="progressFill"></div>
|
||||
</div>
|
||||
<div class="progress-text" id="progressText">준비 중...</div>
|
||||
</div>
|
||||
|
||||
<div class="result-section" id="resultSection">
|
||||
<div class="result-title">🎉 변환 완료!</div>
|
||||
<div class="result-actions">
|
||||
<a href="#" id="downloadBtn" class="btn btn-primary">📥 HTML 다운로드</a>
|
||||
<a href="#" id="previewBtn" class="btn btn-secondary" target="_blank">👁️ 미리보기</a>
|
||||
<a href="/library" class="btn btn-secondary">📚 라이브러리 보기</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let currentJobId = null;
|
||||
const nasConnected = {{ 'true' if nas_status.status == 'connected' else 'false' }};
|
||||
|
||||
// 파일 드래그 앤 드롭
|
||||
const dropZone = document.getElementById('dropZone');
|
||||
const fileInput = document.getElementById('fileInput');
|
||||
const uploadBtn = document.getElementById('uploadBtn');
|
||||
|
||||
if (nasConnected) {
|
||||
dropZone.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.add('drag-over');
|
||||
});
|
||||
|
||||
dropZone.addEventListener('dragleave', () => {
|
||||
dropZone.classList.remove('drag-over');
|
||||
});
|
||||
|
||||
dropZone.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.remove('drag-over');
|
||||
|
||||
const files = e.dataTransfer.files;
|
||||
if (files.length > 0) {
|
||||
handleFileUpload(files[0]);
|
||||
}
|
||||
});
|
||||
|
||||
dropZone.addEventListener('click', () => {
|
||||
if (!uploadBtn.disabled) {
|
||||
fileInput.click();
|
||||
}
|
||||
});
|
||||
|
||||
fileInput.addEventListener('change', (e) => {
|
||||
if (e.target.files.length > 0) {
|
||||
handleFileUpload(e.target.files[0]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function handleFileUpload(file) {
|
||||
if (!nasConnected) {
|
||||
alert('NAS 연결이 필요합니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 파일 크기 검증 (100MB)
|
||||
if (file.size > 100 * 1024 * 1024) {
|
||||
alert('파일 크기는 100MB를 초과할 수 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 파일 형식 검증
|
||||
const allowedTypes = ['.pdf', '.txt', '.docx', '.doc'];
|
||||
const fileExt = '.' + file.name.split('.').pop().toLowerCase();
|
||||
|
||||
if (!allowedTypes.includes(fileExt)) {
|
||||
alert('지원되지 않는 파일 형식입니다. PDF, TXT, DOCX 파일만 업로드 가능합니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
// UI 업데이트
|
||||
document.getElementById('progressSection').style.display = 'block';
|
||||
document.getElementById('resultSection').style.display = 'none';
|
||||
uploadBtn.disabled = true;
|
||||
|
||||
// 파일 업로드
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
try {
|
||||
const response = await fetch('/upload', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || '업로드 실패');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
currentJobId = result.job_id;
|
||||
|
||||
// 진행 상황 모니터링 시작
|
||||
monitorProgress();
|
||||
|
||||
} catch (error) {
|
||||
alert('업로드 실패: ' + error.message);
|
||||
document.getElementById('progressSection').style.display = 'none';
|
||||
uploadBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function monitorProgress() {
|
||||
if (!currentJobId) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/status/${currentJobId}`);
|
||||
const status = await response.json();
|
||||
|
||||
// 진행률 업데이트
|
||||
document.getElementById('progressFill').style.width = status.progress + '%';
|
||||
document.getElementById('progressText').textContent = status.message;
|
||||
|
||||
if (status.status === 'completed') {
|
||||
// 완료 처리
|
||||
document.getElementById('progressSection').style.display = 'none';
|
||||
document.getElementById('resultSection').style.display = 'block';
|
||||
|
||||
// 다운로드 링크 설정
|
||||
document.getElementById('downloadBtn').href = `/download/${currentJobId}`;
|
||||
|
||||
uploadBtn.disabled = false;
|
||||
|
||||
} else if (status.status === 'error') {
|
||||
alert('처리 실패: ' + status.message);
|
||||
document.getElementById('progressSection').style.display = 'none';
|
||||
uploadBtn.disabled = false;
|
||||
|
||||
} else {
|
||||
// 계속 모니터링
|
||||
setTimeout(monitorProgress, 2000); // 2초마다 확인
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('상태 확인 오류:', error);
|
||||
setTimeout(monitorProgress, 5000); // 오류시 5초 후 재시도
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
373
templates/library_organized.html
Normal file
373
templates/library_organized.html
Normal file
@@ -0,0 +1,373 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>문서 라이브러리 - AI 번역 시스템</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
background: white;
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 20px 40px rgba(0,0,0,0.1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.header {
|
||||
background: linear-gradient(135deg, #2563eb, #3b82f6);
|
||||
color: white;
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.header h1 { font-size: 2.5rem; margin-bottom: 10px; }
|
||||
.header p { font-size: 1.1rem; opacity: 0.9; }
|
||||
|
||||
.nav-menu {
|
||||
background: #f8fafc;
|
||||
padding: 20px 40px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.nav-links { display: flex; gap: 30px; }
|
||||
.nav-link {
|
||||
color: #374151;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
transition: color 0.3s ease;
|
||||
}
|
||||
.nav-link:hover { color: #2563eb; }
|
||||
.nav-link.active {
|
||||
color: #2563eb;
|
||||
border-bottom: 2px solid #2563eb;
|
||||
padding-bottom: 5px;
|
||||
}
|
||||
|
||||
.stats-bar {
|
||||
background: #f0f9ff;
|
||||
padding: 15px 40px;
|
||||
border-bottom: 1px solid #bae6fd;
|
||||
display: flex;
|
||||
gap: 30px;
|
||||
font-size: 0.9rem;
|
||||
color: #0369a1;
|
||||
}
|
||||
|
||||
.stat-item { display: flex; align-items: center; gap: 8px; }
|
||||
|
||||
.main-content { padding: 40px; }
|
||||
|
||||
.search-section {
|
||||
margin-bottom: 30px;
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
flex: 1;
|
||||
padding: 15px;
|
||||
border: 2px solid #e5e7eb;
|
||||
border-radius: 12px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.filter-select {
|
||||
padding: 15px;
|
||||
border: 2px solid #e5e7eb;
|
||||
border-radius: 12px;
|
||||
background: white;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.category-section {
|
||||
margin-bottom: 40px;
|
||||
background: #f8fafc;
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.category-header {
|
||||
background: linear-gradient(135deg, #1e40af, #2563eb);
|
||||
color: white;
|
||||
padding: 20px 30px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.category-title { font-size: 1.3rem; font-weight: 600; }
|
||||
.category-count {
|
||||
background: rgba(255,255,255,0.2);
|
||||
padding: 5px 12px;
|
||||
border-radius: 20px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.documents-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(380px, 1fr));
|
||||
gap: 20px;
|
||||
padding: 30px;
|
||||
}
|
||||
|
||||
.document-card {
|
||||
background: white;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
transition: all 0.3s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.document-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 12px 24px rgba(0,0,0,0.1);
|
||||
border-color: #2563eb;
|
||||
}
|
||||
|
||||
.document-title {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
margin-bottom: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.document-meta {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
font-size: 0.85rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.meta-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.language-badge {
|
||||
display: inline-block;
|
||||
padding: 4px 8px;
|
||||
background: #eff6ff;
|
||||
color: #1d4ed8;
|
||||
border-radius: 6px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.document-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 10px 16px;
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem;
|
||||
transition: all 0.3s ease;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #2563eb;
|
||||
color: white;
|
||||
}
|
||||
.btn-primary:hover { background: #1d4ed8; }
|
||||
|
||||
.btn-secondary {
|
||||
background: #f8fafc;
|
||||
color: #374151;
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
.btn-secondary:hover { background: #f1f5f9; }
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 80px 20px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.empty-icon { font-size: 4rem; margin-bottom: 20px; }
|
||||
.empty-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 10px;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.documents-grid { grid-template-columns: 1fr; }
|
||||
.nav-menu { flex-direction: column; gap: 20px; }
|
||||
.stats-bar { flex-wrap: wrap; }
|
||||
.search-section { flex-direction: column; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>📚 문서 라이브러리</h1>
|
||||
<p>체계적으로 분류된 다국어 번역 문서</p>
|
||||
</div>
|
||||
|
||||
<div class="nav-menu">
|
||||
<div class="nav-links">
|
||||
<a href="/" class="nav-link">📤 업로드</a>
|
||||
<a href="/library" class="nav-link active">📚 라이브러리</a>
|
||||
<a href="/system-status" class="nav-link">📊 시스템</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stats-bar">
|
||||
<div class="stat-item">
|
||||
<span>📄</span>
|
||||
<span>총 {{ total_documents }}개 문서</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span>🗂️</span>
|
||||
<span>{{ categories|length }}개 카테고리</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span>🌍</span>
|
||||
<span>다국어 지원</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="main-content">
|
||||
<div class="search-section">
|
||||
<input type="text" class="search-box" placeholder="🔍 문서 제목으로 검색..." id="searchInput">
|
||||
<select class="filter-select" id="categoryFilter">
|
||||
<option value="">모든 카테고리</option>
|
||||
{% for category_name in categories.keys() %}
|
||||
<option value="{{ category_name }}">{{ category_name|replace("_", " ")|title }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{% if categories %}
|
||||
{% for category_name, documents in categories.items() %}
|
||||
<div class="category-section" data-category="{{ category_name }}">
|
||||
<div class="category-header">
|
||||
<div class="category-title">
|
||||
{% if category_name == "English To Korean" %}
|
||||
🇺🇸→🇰🇷 English to Korean
|
||||
{% elif category_name == "Japanese To Korean" %}
|
||||
🇯🇵→🇰🇷 Japanese to Korean
|
||||
{% elif category_name == "Korean Only" %}
|
||||
🇰🇷 Korean Documents
|
||||
{% else %}
|
||||
{{ category_name }}
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="category-count">{{ documents|length }}개</div>
|
||||
</div>
|
||||
|
||||
<div class="documents-grid">
|
||||
{% for doc in documents %}
|
||||
<div class="document-card" data-title="{{ doc.name.lower() }}" data-category="{{ category_name }}">
|
||||
<div class="language-badge">{{ doc.language_display }}</div>
|
||||
<div class="document-title">{{ doc.name }}</div>
|
||||
<div class="document-meta">
|
||||
<div class="meta-item">
|
||||
<span>📅</span>
|
||||
<span>{{ doc.created }}</span>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<span>📊</span>
|
||||
<span>{{ doc.size }}</span>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<span>📁</span>
|
||||
<span>{{ doc.month }}</span>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<span>🌐</span>
|
||||
<span>{{ doc.language_type.replace("-", " ")|title }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="document-actions">
|
||||
<a href="{{ doc.url }}" class="btn btn-primary" target="_blank">👁️ 보기</a>
|
||||
<a href="{{ doc.url }}" class="btn btn-secondary" download="{{ doc.filename }}">📥 다운로드</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">📚</div>
|
||||
<div class="empty-title">아직 변환된 문서가 없습니다</div>
|
||||
<div class="empty-text">PDF나 텍스트 파일을 업로드하여 다국어 문서를 만들어보세요!</div>
|
||||
<a href="/" class="btn btn-primary">📤 첫 번째 문서 업로드</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// 검색 및 필터링
|
||||
const searchInput = document.getElementById('searchInput');
|
||||
const categoryFilter = document.getElementById('categoryFilter');
|
||||
|
||||
function filterDocuments() {
|
||||
const query = searchInput.value.toLowerCase();
|
||||
const selectedCategory = categoryFilter.value;
|
||||
|
||||
// 모든 카테고리 섹션 처리
|
||||
document.querySelectorAll('.category-section').forEach(section => {
|
||||
const categoryName = section.dataset.category;
|
||||
let hasVisibleCards = false;
|
||||
|
||||
// 카테고리 필터 확인
|
||||
if (selectedCategory && categoryName !== selectedCategory) {
|
||||
section.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
// 카드 필터링
|
||||
section.querySelectorAll('.document-card').forEach(card => {
|
||||
const title = card.dataset.title;
|
||||
const matchesSearch = !query || title.includes(query);
|
||||
|
||||
if (matchesSearch) {
|
||||
card.style.display = 'block';
|
||||
hasVisibleCards = true;
|
||||
} else {
|
||||
card.style.display = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
// 카테고리 섹션 표시/숨김
|
||||
section.style.display = hasVisibleCards ? 'block' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
searchInput.addEventListener('input', filterDocuments);
|
||||
categoryFilter.addEventListener('change', filterDocuments);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user