feat: 초기 프로젝트 구조 설정 및 소스 코드 추가

This commit is contained in:
hyungi
2025-07-24 15:06:29 +09:00
parent 9003616737
commit 5db20e2943
17 changed files with 4840 additions and 0 deletions

47
.gitignore vendored Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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('<', '&lt;').replace('>', '&gt;')
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()

View 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
View 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
View 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
View 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
View 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>

View 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>