Squashed 'integrations/document-ai/' content from commit 9093611

git-subtree-dir: integrations/document-ai
git-subtree-split: 9093611c9629c0de3db760878ec9929f50add5ed
This commit is contained in:
hyungi
2025-08-13 08:38:41 +09:00
commit 397efb86dc
26 changed files with 6450 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/

102
CODING_CONVENTIONS.md Normal file
View File

@@ -0,0 +1,102 @@
# NLLB 번역 시스템 코딩 규칙
이 문서는 NLLB 번역 시스템 프로젝트의 코드 일관성과 품질을 유지하기 위한 코딩 규칙을 정의합니다.
## 1. 일반 원칙
- **언어**: 모든 코드와 주석은 **한국어**로 작성하는 것을 원칙으로 합니다. 단, 외부 라이브러리 이름이나 기술 용어 등은 예외로 합니다.
- **인코딩**: 모든 파일은 **UTF-8**로 인코딩합니다.
- **명령형**: 커밋 메시지, 함수/메서드 설명 등은 명령형(`~하기`, `~함`)으로 작성하여 명확성을 높입니다.
## 2. 코드 스타일
- **들여쓰기**: 공백 4칸을 사용합니다.
- **최대 줄 길이**: 한 줄은 120자를 넘지 않도록 합니다.
- **명명 규칙**:
- **변수**: `snake_case` (예: `translated_text`)
- **함수/메서드**: `snake_case` (예: `load_models`)
- **클래스**: `PascalCase` (예: `IntegratedTranslationSystem`)
- **상수**: `UPPER_SNAKE_CASE` (예: `NAS_MOUNT_POINT`)
- **주석**:
- 복잡한 로직이나 중요한 결정 사항에 대해서는 `#`을 사용하여 간결한 주석을 작성합니다.
- 파일의 목적과 전반적인 설명을 위해 파일 최상단에 `"""독스트링"""`을 작성합니다.
- **타입 힌팅**: 함수의 인자와 반환 값에는 타입 힌트를 명시하여 코드의 명확성을 높입니다. (예: `def process_document(input_path: str) -> TranslationResult:`)
## 3. Python 관련 규칙
- **임포트**:
1. 표준 라이브러리
2. 서드파티 라이브러리
3. 로컬 애플리케이션/라이브러리 순으로 그룹화하고, 각 그룹 사이에는 한 줄을 띄웁니다.
- 예시:
```python
import asyncio
import json
from pathlib import Path
from fastapi import FastAPI
import torch
from .integrated_translation_system import IntegratedTranslationSystem
```
- **Docstrings**: 모든 모듈, 함수, 클래스, 메서드에 대해 Google Python 스타일 가이드를 따르는 독스트링을 작성합니다.
- **오류 처리**: `try...except` 블록을 사용하여 예상되는 오류를 명시적으로 처리하고, 오류 발생 시 로그를 남기거나 적절한 예외를 발생시킵니다.
## 4. FastAPI 관련 규칙
- **엔드포인트**:
- URL 경로는 소문자와 하이픈(`-`)을 사용합니다. (예: `/system-status`)
- 응답 모델을 사용하여 API의 입출력 형식을 명확히 정의합니다.
- **비동기 처리**: I/O 바운드 작업(파일 읽기/쓰기, 네트워크 요청 등)에는 `async`와 `await`를 적극적으로 사용하여 비동기 처리를 구현합니다.
- **백그라운드 작업**: 시간이 오래 걸리는 작업(모델 로딩, 번역 등)은 `BackgroundTasks`를 사용하여 사용자 경험을 저해하지 않도록 합니다.
## 5. 프로젝트 구조
- `config/`: 설정 파일 (예: `settings.json`)
- `data/`: 원본 데이터 및 학습 데이터
- `models/`: 학습된 AI 모델 파일
- `src/`: 핵심 소스 코드
- `tests/`: 테스트 코드
- `static/`, `templates/`: FastAPI 웹 인터페이스 파일
## 6. 네트워크 설정
- **Mac Mini IP**: `192.168.1.122` (AI 번역 서버 호스트)
- **DS1525+ NAS IP**: `192.168.1.227` (문서 저장소)
- **NAS 마운트 포인트**: `/Volumes/DS1525+`
- **서버 포트**: `8080` (FastAPI 웹 서비스)
- **대시보드 접속**: `http://192.168.1.122:8080/dashboard`
### 6.1. API 키 인증
관리자용 API(`/api/restart-models`, `/api/clear-cache` 등)를 호출하려면 HTTP 요청 헤더에 API 키를 포함해야 합니다.
- **헤더 이름**: `X-API-KEY`
- **API 키 값**: `config/settings.json` 파일의 `security.api_key` 값을 사용합니다.
**cURL 예시:**
```bash
curl -X POST http://192.168.1.122:20080/api/restart-models \
-H "X-API-KEY: nllb-secret-key-!@#$%"
```
## 7. 서버 운영 및 배포 (macOS)
이 서버는 24/7 무중단 운영을 위해 macOS의 `launchd` 서비스를 통해 관리됩니다. 이를 통해 시스템 재부팅 시 자동 시작 및 예기치 않은 종료 시 자동 재시작이 보장됩니다.
- **서비스 설정 파일**: `~/Library/LaunchAgents/com.nllb-translation-system.app.plist`
- 이 파일은 서비스의 실행 방법, 로그 경로, 자동 재시작 여부 등을 정의합니다.
- **서비스 제어 명령어**:
- **시작**: `launchctl start com.nllb-translation-system.app`
- **중지**: `launchctl stop com.nllb-translation-system.app`
- **재시작 (설정 파일 수정 후)**:
1. `launchctl unload ~/Library/LaunchAgents/com.nllb-translation-system.app.plist`
2. `launchctl load ~/Library/LaunchAgents/com.nllb-translation-system.app.plist`
- **로그 확인**:
- **일반 로그**: `tail -f logs/service.log`
- **에러 로그**: `tail -f logs/service_error.log`
- **주의**: 개발 및 디버깅 시에는 `launchd` 서비스를 중지(`launchctl stop ...`)한 후, 터미널에서 직접 `python src/fastapi_with_dashboard.py`를 실행해야 포트 충돌이 발생하지 않습니다.

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("패키지 재설치 필요")

45
config/settings.json Normal file
View File

@@ -0,0 +1,45 @@
{
"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
},
"network": {
"mac_mini_ip": "192.168.1.122",
"nas_ip": "192.168.1.227",
"server_port": 20080
},
"paths": {
"nas_mount_point": "/Volumes/Media",
"document_upload_base": "Document-upload",
"originals": "originals",
"translated": "translated",
"static_hosting": "static-hosting",
"metadata": "metadata",
"local_work_path": "~/Scripts/nllb-translation-system"
},
"security": {
"api_key": "nllb-secret-key-!@#$%"
}
}

11
logs/service.log Normal file
View File

@@ -0,0 +1,11 @@
번역 시스템 초기화 (디바이스: mps)
모델 로딩 중...
NLLB 번역 모델...
INFO: 192.168.1.122:51436 - "GET /api/dashboard HTTP/1.1" 200 OK
NLLB 모델 로드 완료
KoBART 요약 모델...
KoBART 모델 로드 완료
모델 로딩 완료!
INFO: 192.168.1.122:51443 - "GET /api/dashboard HTTP/1.1" 200 OK
INFO: 192.168.1.122:51518 - "GET /api/dashboard HTTP/1.1" 200 OK
INFO: 192.168.1.122:51563 - "GET /api/dashboard HTTP/1.1" 200 OK

23
logs/service_error.log Normal file
View File

@@ -0,0 +1,23 @@
2025-07-25 06:59:45,307 - __main__ - INFO - 🚀 Mac Mini AI 번역 서버 with 대시보드 (v2.1)
2025-07-25 06:59:45,307 - __main__ - INFO - 📡 서버 주소: http://192.168.1.122:20080
2025-07-25 06:59:45,307 - __main__ - INFO - 📊 대시보드: http://192.168.1.122:20080/dashboard
2025-07-25 06:59:45,307 - __main__ - INFO - 📁 NAS 주소: 192.168.1.227
INFO: Started server process [20661]
INFO: Waiting for application startup.
2025-07-25 06:59:45,328 - __main__ - INFO - 🚀 Mac Mini AI 번역 서버 시작 (v2.1)
2025-07-25 06:59:45,328 - __main__ - INFO - 📍 Mac Mini IP: 192.168.1.122
2025-07-25 06:59:45,328 - __main__ - INFO - 📍 NAS IP: 192.168.1.227
2025-07-25 06:59:45,328 - __main__ - INFO - --------------------------------------------------
2025-07-25 06:59:45,328 - background_ai_service - INFO - 🚀 백그라운드 AI 서비스 시작
2025-07-25 06:59:45,328 - background_ai_service - INFO - ✅ 백그라운드 서비스 준비 완료
2025-07-25 06:59:45,351 - __main__ - INFO - ✅ NAS 연결 정상: /Volumes/Media
2025-07-25 06:59:45,357 - __main__ - INFO - ✅ 폴더 구조 확인/생성 완료
INFO: Application startup complete.
INFO: Uvicorn running on http://0.0.0.0:20080 (Press CTRL+C to quit)
Loading checkpoint shards: 0%| | 0/3 [00:00<?, ?it/s]
Loading checkpoint shards: 33%|███▎ | 1/3 [00:00<00:01, 1.80it/s]
Loading checkpoint shards: 67%|██████▋ | 2/3 [00:01<00:00, 1.68it/s]
Loading checkpoint shards: 100%|██████████| 3/3 [00:01<00:00, 2.66it/s]
Loading checkpoint shards: 100%|██████████| 3/3 [00:01<00:00, 2.32it/s]
You passed along `num_labels=3` with an incompatible id to label map: {'0': 'NEGATIVE', '1': 'POSITIVE'}. The number of labels will be overwritten to 2.

35
requirements.txt Normal file
View File

@@ -0,0 +1,35 @@
# 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
# 시스템 모니터링 및 백그라운드 서비스
psutil>=5.9.0

View File

@@ -0,0 +1,283 @@
#!/usr/bin/env python3
"""
백그라운드 AI 모델 서비스 및 모니터링 시스템
실시간 모델 상태 추적 및 성능 지표 수집
"""
import asyncio
import time
import psutil
import threading
from datetime import datetime, timedelta
from dataclasses import dataclass, asdict
from typing import Dict, List, Optional
from pathlib import Path
import json
import logging
from integrated_translation_system import IntegratedTranslationSystem
@dataclass
class ModelStatus:
"""개별 모델 상태 정보"""
name: str
status: str # "loading", "ready", "error", "unloaded"
load_time: Optional[float] = None
memory_usage_mb: float = 0.0
last_used: Optional[datetime] = None
error_message: Optional[str] = None
total_processed: int = 0
@dataclass
class SystemMetrics:
"""전체 시스템 성능 지표"""
total_memory_usage_mb: float
cpu_usage_percent: float
active_jobs: int
queued_jobs: int
completed_jobs_today: int
average_processing_time: float
uptime_seconds: float
class BackgroundAIService:
"""백그라운드 AI 모델 서비스 관리자"""
def __init__(self):
self.models_status: Dict[str, ModelStatus] = {}
self.translation_system: Optional[IntegratedTranslationSystem] = None
self.start_time = datetime.now()
self.job_queue = asyncio.Queue()
self.active_jobs = {}
self.completed_jobs = []
self.metrics_history = []
# 로깅 설정
self.logger = logging.getLogger(__name__)
self.logger.setLevel(logging.INFO)
# 모델 상태 초기화
self._initialize_model_status()
# 백그라운드 워커 및 모니터링 시작
self.worker_task = None
self.monitoring_task = None
def _initialize_model_status(self):
"""모델 상태 초기화"""
model_names = ["NLLB 번역", "KoBART 요약"]
for name in model_names:
self.models_status[name] = ModelStatus(
name=name,
status="unloaded"
)
async def start_service(self):
"""백그라운드 서비스 시작"""
self.logger.info("🚀 백그라운드 AI 서비스 시작")
# 모델 로딩 시작
asyncio.create_task(self._load_models())
# 백그라운드 워커 시작
self.worker_task = asyncio.create_task(self._process_job_queue())
# 모니터링 시작
self.monitoring_task = asyncio.create_task(self._collect_metrics())
self.logger.info("✅ 백그라운드 서비스 준비 완료")
async def _load_models(self):
"""AI 모델들을 순차적으로 로딩"""
try:
# NLLB 모델 로딩 시작
self.models_status["NLLB 번역"].status = "loading"
load_start = time.time()
self.translation_system = IntegratedTranslationSystem()
# 실제 모델 로딩
success = await asyncio.to_thread(self.translation_system.load_models)
if success:
load_time = time.time() - load_start
# 각 모델 상태 업데이트
self.models_status["NLLB 번역"].status = "ready"
self.models_status["NLLB 번역"].load_time = load_time
self.models_status["KoBART 요약"].status = "ready"
self.models_status["KoBART 요약"].load_time = load_time
self.logger.info(f"✅ 모든 AI 모델 로딩 완료 ({load_time:.1f}초)")
else:
# 로딩 실패
for model_name in self.models_status.keys():
self.models_status[model_name].status = "error"
self.models_status[model_name].error_message = "모델 로딩 실패"
self.logger.error("❌ AI 모델 로딩 실패")
except Exception as e:
self.logger.error(f"❌ 모델 로딩 중 오류: {e}")
for model_name in self.models_status.keys():
self.models_status[model_name].status = "error"
self.models_status[model_name].error_message = str(e)
def _get_memory_usage(self) -> float:
"""현재 프로세스의 메모리 사용량 반환 (MB)"""
try:
process = psutil.Process()
return process.memory_info().rss / 1024 / 1024 # bytes to MB
except:
return 0.0
async def _collect_metrics(self):
"""주기적으로 시스템 메트릭 수집"""
while True:
try:
# 메모리 사용량 업데이트
total_memory = self._get_memory_usage()
# 각 모델별 메모리 사용량 추정 (실제로는 더 정교한 측정 필요)
if self.models_status["NLLB 번역"].status == "ready":
self.models_status["NLLB 번역"].memory_usage_mb = total_memory * 0.6
if self.models_status["KoBART 요약"].status == "ready":
self.models_status["KoBART 요약"].memory_usage_mb = total_memory * 0.4
# 전체 시스템 메트릭 수집
metrics = SystemMetrics(
total_memory_usage_mb=total_memory,
cpu_usage_percent=psutil.cpu_percent(),
active_jobs=len(self.active_jobs),
queued_jobs=self.job_queue.qsize(),
completed_jobs_today=len([
job for job in self.completed_jobs
if job.get('completed_at', datetime.min).date() == datetime.now().date()
]),
average_processing_time=self._calculate_average_processing_time(),
uptime_seconds=(datetime.now() - self.start_time).total_seconds()
)
# 메트릭 히스토리에 추가 (최근 100개만 유지)
self.metrics_history.append({
'timestamp': datetime.now(),
'metrics': metrics
})
if len(self.metrics_history) > 100:
self.metrics_history.pop(0)
await asyncio.sleep(5) # 5초마다 수집
except Exception as e:
self.logger.error(f"메트릭 수집 오류: {e}")
await asyncio.sleep(10)
def _calculate_average_processing_time(self) -> float:
"""최근 처리된 작업들의 평균 처리 시간 계산"""
recent_jobs = [
job for job in self.completed_jobs[-20:] # 최근 20개
if 'processing_time' in job
]
if not recent_jobs:
return 0.0
return sum(job['processing_time'] for job in recent_jobs) / len(recent_jobs)
async def _process_job_queue(self):
"""작업 큐 처리 워커"""
while True:
try:
# 큐에서 작업 가져오기
job = await self.job_queue.get()
job_id = job['job_id']
# 활성 작업에 추가
self.active_jobs[job_id] = {
'start_time': datetime.now(),
'job_data': job
}
# 실제 AI 처리
await self._process_single_job(job)
# 완료된 작업으로 이동
processing_time = (datetime.now() - self.active_jobs[job_id]['start_time']).total_seconds()
self.completed_jobs.append({
'job_id': job_id,
'completed_at': datetime.now(),
'processing_time': processing_time
})
# 활성 작업에서 제거
del self.active_jobs[job_id]
# 모델 사용 시간 업데이트
for model_status in self.models_status.values():
if model_status.status == "ready":
model_status.last_used = datetime.now()
model_status.total_processed += 1
except Exception as e:
self.logger.error(f"작업 처리 오류: {e}")
if job_id in self.active_jobs:
del self.active_jobs[job_id]
async def _process_single_job(self, job):
"""개별 작업 처리"""
# 기존 번역 시스템 로직 사용
if self.translation_system and self.models_status["NLLB 번역"].status == "ready":
result = await asyncio.to_thread(
self.translation_system.process_document,
job['file_path']
)
return result
else:
raise Exception("AI 모델이 준비되지 않음")
async def add_job(self, job_data: Dict):
"""새 작업을 큐에 추가"""
await self.job_queue.put(job_data)
def get_dashboard_data(self) -> Dict:
"""대시보드용 데이터 반환"""
current_metrics = self.metrics_history[-1]['metrics'] if self.metrics_history else None
return {
'models_status': {name: asdict(status) for name, status in self.models_status.items()},
'current_metrics': asdict(current_metrics) if current_metrics else None,
'recent_metrics': [
{
'timestamp': entry['timestamp'].isoformat(),
'metrics': asdict(entry['metrics'])
}
for entry in self.metrics_history[-20:] # 최근 20개
],
'active_jobs': len(self.active_jobs),
'completed_today': len([
job for job in self.completed_jobs
if job.get('completed_at', datetime.min).date() == datetime.now().date()
])
}
async def restart_models(self):
"""모델 재시작"""
self.logger.info("🔄 AI 모델 재시작 중...")
# 모든 모델 상태를 재로딩으로 설정
for model_status in self.models_status.values():
model_status.status = "loading"
model_status.error_message = None
# 기존 시스템 정리
if self.translation_system:
del self.translation_system
# 모델 재로딩
await self._load_models()
# 전역 서비스 인스턴스
ai_service = BackgroundAIService()

67
src/config_loader.py Normal file
View File

@@ -0,0 +1,67 @@
#!/usr/bin/env python3
"""
설정 파일(settings.json) 로더
프로젝트 전체에서 사용되는 설정을 중앙에서 관리하고 제공합니다.
"""
import json
from pathlib import Path
from typing import Dict, Any
class ConfigLoader:
def __init__(self, config_path: str = "config/settings.json"):
self.config_path = Path(config_path)
self.config = self._load_config()
def _load_config(self) -> Dict[str, Any]:
"""JSON 설정 파일을 읽어 딕셔너리로 반환합니다."""
if not self.config_path.exists():
raise FileNotFoundError(f"설정 파일을 찾을 수 없습니다: {self.config_path}")
with open(self.config_path, 'r', encoding='utf-8') as f:
config_data = json.load(f)
# 경로 설정에서 '~'를 실제 홈 디렉토리로 확장
if 'paths' in config_data:
for key, value in config_data['paths'].items():
if isinstance(value, str) and value.startswith('~/'):
config_data['paths'][key] = str(Path.home() / value[2:])
return config_data
def get_section(self, section_name: str) -> Dict[str, Any]:
"""설정의 특정 섹션을 반환합니다."""
return self.config.get(section_name, {})
@property
def network_config(self) -> Dict[str, Any]:
return self.get_section("network")
@property
def paths_config(self) -> Dict[str, Any]:
return self.get_section("paths")
@property
def models_config(self) -> Dict[str, Any]:
return self.get_section("models")
# 전역 설정 인스턴스 생성
# 프로젝트 어디서든 `from config_loader import settings`로 불러와 사용 가능
settings = ConfigLoader()
if __name__ == "__main__":
# 설정 로더 테스트
print("✅ 설정 로더 테스트")
print("-" * 30)
network = settings.network_config
print(f"네트워크 설정: {network}")
print(f" - 서버 IP: {network.get('mac_mini_ip')}")
print(f" - 서버 포트: {network.get('server_port')}")
paths = settings.paths_config
print(f"경로 설정: {paths}")
print(f" - 로컬 작업 경로: {paths.get('local_work_path')}")
print("-" * 30)
print("설정 로드 완료!")

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.227"
NAS_IP = "192.168.1.122"
# 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)

View File

@@ -0,0 +1,408 @@
#!/usr/bin/env python3
"""
Mac Mini FastAPI + DS1525+ 연동
백그라운드 AI 서비스 및 대시보드 통합 버전 (v2.1 - 설정 중앙화 및 Lifespan 적용)
"""
from contextlib import asynccontextmanager
from fastapi import FastAPI, File, UploadFile, HTTPException, BackgroundTasks, Request, Depends
from fastapi.templating import Jinja2Templates
from fastapi.staticfiles import StaticFiles
from fastapi.responses import HTMLResponse, JSONResponse
import asyncio
import uuid
from pathlib import Path
from typing import Dict
import time
import shutil
import aiofiles
from datetime import datetime
import subprocess
import logging
# 중앙 설정 로더 및 AI 서비스 임포트
from config_loader import settings
from background_ai_service import ai_service
from security import get_api_key
# 로깅 설정
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
# 설정에서 값 불러오기
network_cfg = settings.network_config
paths_cfg = settings.paths_config
MAC_MINI_IP = network_cfg.get("mac_mini_ip")
NAS_IP = network_cfg.get("nas_ip")
SERVER_PORT = network_cfg.get("server_port")
NAS_MOUNT_POINT = Path(paths_cfg.get("nas_mount_point"))
DOCUMENT_UPLOAD_BASE = NAS_MOUNT_POINT / paths_cfg.get("document_upload_base")
NAS_ORIGINALS_PATH = DOCUMENT_UPLOAD_BASE / paths_cfg.get("originals")
NAS_TRANSLATED_PATH = DOCUMENT_UPLOAD_BASE / paths_cfg.get("translated")
NAS_STATIC_HOSTING_PATH = DOCUMENT_UPLOAD_BASE / paths_cfg.get("static_hosting")
NAS_METADATA_PATH = DOCUMENT_UPLOAD_BASE / paths_cfg.get("metadata")
LOCAL_WORK_PATH = Path(paths_cfg.get("local_work_path"))
@asynccontextmanager
async def lifespan(app: FastAPI):
"""FastAPI 라이프사이클 이벤트 핸들러"""
logger.info(f"🚀 Mac Mini AI 번역 서버 시작 (v2.1)")
logger.info(f"📍 Mac Mini IP: {MAC_MINI_IP}")
logger.info(f"📍 NAS IP: {NAS_IP}")
logger.info("-" * 50)
# 백그라운드 AI 서비스 시작
await ai_service.start_service()
# NAS 연결 및 폴더 구조 확인
nas_status = check_nas_connection()
if nas_status["status"] == "connected":
logger.info(f"✅ NAS 연결 정상: {nas_status['mount_point']}")
create_nas_folders()
else:
logger.error(f"❌ NAS 연결 실패: {nas_status['error']}")
logger.info("해결 방법:")
logger.info("1. NAS 전원 및 네트워크 상태 확인")
logger.info("2. Finder에서 DS1525+ 수동 연결:")
logger.info(f" - 이동 → 서버에 연결 → smb://{NAS_IP}")
logger.info("3. 연결 후 서버 재시작")
yield
# 종료 시 실행될 코드 (필요 시)
logger.info("👋 서버 종료.")
app = FastAPI(
title="AI 번역 시스템 with 대시보드",
description=f"Mac Mini ({MAC_MINI_IP}) + DS1525+ ({NAS_IP}) 연동 + 실시간 모니터링",
version="2.1.0",
lifespan=lifespan
)
# 정적 파일 및 템플릿
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)}
def create_nas_folders():
"""NAS에 필요한 폴더 구조 자동 생성"""
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)
logger.info(f"✅ 폴더 구조 확인/생성 완료")
except Exception as e:
logger.warning(f"⚠️ 폴더 생성 실패: {e}")
# ==========================================
# 기존 엔드포인트들
# ==========================================
@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 = ai_service.get_dashboard_data()
# 작업 통계
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(...)):
"""파일 업로드 (백그라운드 AI 서비스 연동)"""
# 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)
logger.info(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
# 백그라운드 AI 서비스에 작업 추가
await ai_service.add_job({
'job_id': job_id,
'file_path': str(nas_file_path),
'original_filename': file.filename
})
return {
"job_id": job_id,
"message": "파일 업로드 완료, 백그라운드 AI 처리를 시작합니다.",
"nas_path": str(nas_file_path)
}
@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("/dashboard", response_class=HTMLResponse)
async def dashboard(request: Request):
"""AI 모델 대시보드 페이지"""
return templates.TemplateResponse("dashboard.html", {
"request": request
})
@app.get("/api/dashboard")
async def get_dashboard_data():
"""대시보드용 실시간 데이터 API"""
return ai_service.get_dashboard_data()
@app.post("/api/restart-models")
async def restart_models(api_key: str = Depends(get_api_key)):
"""AI 모델 재시작 API (보안)"""
try:
await ai_service.restart_models()
return {"message": "AI 모델 재시작이 완료되었습니다."}
except Exception as e:
raise HTTPException(status_code=500, detail=f"모델 재시작 실패: {str(e)}")
@app.post("/api/clear-cache")
async def clear_cache(api_key: str = Depends(get_api_key)):
"""시스템 캐시 정리 API (보안)"""
try:
# 여기서 실제 캐시 정리 로직 구현
# 예: 임시 파일 삭제, 메모리 정리 등
import gc
gc.collect() # 가비지 컬렉션 실행
return {"message": "시스템 캐시가 정리되었습니다."}
except Exception as e:
raise HTTPException(status_code=500, detail=f"캐시 정리 실패: {str(e)}")
@app.get("/api/models/status")
async def get_models_status():
"""AI 모델 상태만 조회하는 API"""
dashboard_data = ai_service.get_dashboard_data()
return dashboard_data.get('models_status', {})
@app.get("/api/metrics/history")
async def get_metrics_history():
"""성능 지표 히스토리 조회 API"""
dashboard_data = ai_service.get_dashboard_data()
return dashboard_data.get('recent_metrics', [])
# ==========================================
# 유틸리티 함수들
# ==========================================
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,
"ai_service_version": "2.0"
}
# 월별 로그 파일에 추가
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:
logger.error(f"메타데이터 저장 실패: {e}")
if __name__ == "__main__":
import uvicorn
logger.info(f"🚀 Mac Mini AI 번역 서버 with 대시보드 (v2.1)")
logger.info(f"📡 서버 주소: http://{MAC_MINI_IP}:{SERVER_PORT}")
logger.info(f"📊 대시보드: http://{MAC_MINI_IP}:{SERVER_PORT}/dashboard")
logger.info(f"📁 NAS 주소: {NAS_IP}")
uvicorn.run(app, host="0.0.0.0", port=SERVER_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()

73
src/security.py Normal file
View File

@@ -0,0 +1,73 @@
#!/usr/bin/env python3
"""
API 보안 및 인증 모듈
API 키 기반의 인증을 처리합니다.
"""
from fastapi import Security, HTTPException
from fastapi.security import APIKeyHeader
from starlette import status
from config_loader import settings
# 설정에서 API 키 가져오기
API_KEY = settings.get_section("security").get("api_key")
API_KEY_NAME = "X-API-KEY"
# API 키 헤더 정의
api_key_header = APIKeyHeader(name=API_KEY_NAME, auto_error=False)
async def get_api_key(header_value: str = Security(api_key_header)):
"""
요청 헤더에서 API 키를 추출하고 유효성을 검증합니다.
유효하지 않은 경우, HTTPException을 발생시킵니다.
"""
if not header_value:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="API 키가 필요합니다."
)
if header_value != API_KEY:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="제공된 API 키가 유효하지 않습니다."
)
return header_value
if __name__ == "__main__":
# 보안 모듈 테스트
print("✅ 보안 모듈 테스트")
print("-" * 30)
print(f" - 설정된 API 키 이름: {API_KEY_NAME}")
print(f" - 설정된 API 키 값: {API_KEY}")
# 예제: get_api_key 함수 테스트 (비동기 함수라 직접 실행은 어려움)
async def test_key_validation():
print("\n[테스트 시나리오]")
# 1. 유효한 키
try:
await get_api_key(API_KEY)
print(" - 유효한 키 검증: 성공 ✅")
except HTTPException as e:
print(f" - 유효한 키 검증: 실패 ❌ ({e.detail})")
# 2. 유효하지 않은 키
try:
await get_api_key("invalid-key")
print(" - 유효하지 않은 키 검증: 성공 ✅")
except HTTPException as e:
print(f" - 유효하지 않은 키 검증: 실패 ❌ ({e.detail})")
# 3. 키 없음
try:
await get_api_key(None)
print(" - 키 없음 검증: 성공 ✅")
except HTTPException as e:
print(f" - 키 없음 검증: 실패 ❌ ({e.detail})")
import asyncio
asyncio.run(test_key_validation())
print("-" * 30)

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("📝 요약 없이 번역만으로 진행 고려")

585
templates/dashboard.html Normal file
View File

@@ -0,0 +1,585 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AI 모델 대시보드 - NLLB 번역 시스템</title>
<style>
:root {
--primary-color: #2563eb;
--success-color: #059669;
--warning-color: #d97706;
--error-color: #dc2626;
--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);
}
.dashboard-container {
max-width: 1400px;
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);
}
.header h1 {
font-size: 2rem;
font-weight: 700;
color: var(--primary-color);
margin-bottom: 8px;
}
.header .subtitle {
color: #64748b;
font-size: 1.1rem;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
gap: 24px;
margin-bottom: 24px;
}
.card {
background: white;
border-radius: 12px;
padding: 24px;
box-shadow: var(--shadow);
border: 1px solid var(--border-color);
}
.card-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 20px;
padding-bottom: 12px;
border-bottom: 1px solid var(--border-color);
}
.card-header h3 {
font-size: 1.25rem;
font-weight: 600;
color: var(--text-color);
}
.card-icon {
font-size: 1.5rem;
}
.model-status {
display: flex;
flex-direction: column;
gap: 16px;
}
.model-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
background: #f8fafc;
border-radius: 8px;
border: 1px solid var(--border-color);
}
.model-info h4 {
font-weight: 600;
margin-bottom: 4px;
}
.model-details {
font-size: 0.875rem;
color: #64748b;
}
.status-badge {
padding: 6px 12px;
border-radius: 20px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
}
.status-ready {
background: #dcfce7;
color: var(--success-color);
}
.status-loading {
background: #fef3c7;
color: var(--warning-color);
}
.status-error {
background: #fee2e2;
color: var(--error-color);
}
.status-unloaded {
background: #f1f5f9;
color: #64748b;
}
.metrics-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
}
.metric-item {
text-align: center;
padding: 16px;
background: #f8fafc;
border-radius: 8px;
}
.metric-value {
font-size: 2rem;
font-weight: 700;
color: var(--primary-color);
margin-bottom: 4px;
}
.metric-label {
font-size: 0.875rem;
color: #64748b;
}
.progress-bar {
width: 100%;
height: 8px;
background: #e2e8f0;
border-radius: 4px;
overflow: hidden;
margin: 8px 0;
}
.progress-fill {
height: 100%;
background: var(--primary-color);
transition: width 0.3s ease;
}
.control-buttons {
display: flex;
gap: 12px;
margin-top: 20px;
}
.btn {
padding: 10px 20px;
border: none;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
font-size: 0.875rem;
}
.btn-primary {
background: var(--primary-color);
color: white;
}
.btn-primary:hover {
background: #1d4ed8;
}
.btn-secondary {
background: #64748b;
color: white;
}
.btn-secondary:hover {
background: #475569;
}
.job-list {
max-height: 300px;
overflow-y: auto;
}
.job-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 0;
border-bottom: 1px solid var(--border-color);
}
.job-item:last-child {
border-bottom: none;
}
.job-id {
font-family: monospace;
font-size: 0.75rem;
color: #64748b;
}
.auto-refresh {
position: fixed;
top: 20px;
right: 20px;
background: white;
padding: 12px;
border-radius: 8px;
box-shadow: var(--shadow);
border: 1px solid var(--border-color);
}
.refresh-indicator {
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--success-color);
margin-right: 8px;
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.chart-container {
height: 200px;
position: relative;
margin-top: 16px;
}
@media (max-width: 768px) {
.dashboard-container {
padding: 12px;
}
.grid {
grid-template-columns: 1fr;
}
.metrics-grid {
grid-template-columns: 1fr;
}
}
</style>
</head>
<body>
<div class="auto-refresh">
<div style="display: flex; align-items: center;">
<div class="refresh-indicator"></div>
<span style="font-size: 0.875rem;">실시간 업데이트</span>
</div>
</div>
<div class="dashboard-container">
<div class="header">
<h1>🤖 AI 모델 대시보드</h1>
<p class="subtitle">NLLB 번역 시스템 - 실시간 모니터링</p>
</div>
<div class="grid">
<!-- 모델 상태 카드 -->
<div class="card">
<div class="card-header">
<span class="card-icon">🧠</span>
<h3>AI 모델 상태</h3>
</div>
<div class="model-status" id="modelStatus">
<!-- 동적으로 채워짐 -->
</div>
<div class="control-buttons">
<button class="btn btn-primary" onclick="restartModels()">
🔄 모델 재시작
</button>
<button class="btn btn-secondary" onclick="clearCache()">
🗑️ 캐시 정리
</button>
</div>
</div>
<!-- 시스템 성능 카드 -->
<div class="card">
<div class="card-header">
<span class="card-icon">📊</span>
<h3>시스템 성능</h3>
</div>
<div class="metrics-grid" id="systemMetrics">
<!-- 동적으로 채워짐 -->
</div>
</div>
<!-- 작업 통계 카드 -->
<div class="card">
<div class="card-header">
<span class="card-icon">📈</span>
<h3>작업 통계</h3>
</div>
<div class="metrics-grid" id="jobStats">
<!-- 동적으로 채워짐 -->
</div>
<div class="chart-container">
<canvas id="performanceChart" width="400" height="200"></canvas>
</div>
</div>
<!-- 활성 작업 카드 -->
<div class="card">
<div class="card-header">
<span class="card-icon"></span>
<h3>활성 작업</h3>
</div>
<div class="job-list" id="activeJobs">
<!-- 동적으로 채워짐 -->
</div>
</div>
</div>
</div>
<script>
// 대시보드 데이터 관리
let dashboardData = {};
let performanceChart = null;
// 페이지 로드 시 초기화
document.addEventListener('DOMContentLoaded', function() {
initializeChart();
loadDashboardData();
// 5초마다 자동 새로고침
setInterval(loadDashboardData, 5000);
});
// 대시보드 데이터 로드
async function loadDashboardData() {
try {
const response = await fetch('/api/dashboard');
dashboardData = await response.json();
updateModelStatus();
updateSystemMetrics();
updateJobStats();
updateActiveJobs();
updatePerformanceChart();
} catch (error) {
console.error('대시보드 데이터 로드 실패:', error);
}
}
// 모델 상태 업데이트
function updateModelStatus() {
const container = document.getElementById('modelStatus');
container.innerHTML = '';
if (dashboardData.models_status) {
Object.values(dashboardData.models_status).forEach(model => {
const modelItem = document.createElement('div');
modelItem.className = 'model-item';
const memoryUsage = model.memory_usage_mb ? `${model.memory_usage_mb.toFixed(0)}MB` : 'N/A';
const totalProcessed = model.total_processed || 0;
const lastUsed = model.last_used ? new Date(model.last_used).toLocaleTimeString() : 'N/A';
modelItem.innerHTML = `
<div class="model-info">
<h4>${model.name}</h4>
<div class="model-details">
메모리: ${memoryUsage} | 처리 완료: ${totalProcessed}개 | 마지막 사용: ${lastUsed}
</div>
</div>
<span class="status-badge status-${model.status}">${getStatusText(model.status)}</span>
`;
container.appendChild(modelItem);
});
}
}
// 시스템 메트릭 업데이트
function updateSystemMetrics() {
const container = document.getElementById('systemMetrics');
container.innerHTML = '';
if (dashboardData.current_metrics) {
const metrics = dashboardData.current_metrics;
const metricItems = [
{ value: `${metrics.total_memory_usage_mb.toFixed(0)}MB`, label: '메모리 사용량' },
{ value: `${metrics.cpu_usage_percent.toFixed(1)}%`, label: 'CPU 사용률' },
{ value: formatUptime(metrics.uptime_seconds), label: '서비스 가동시간' },
{ value: `${metrics.average_processing_time.toFixed(1)}`, label: '평균 처리시간' }
];
metricItems.forEach(item => {
const metricDiv = document.createElement('div');
metricDiv.className = 'metric-item';
metricDiv.innerHTML = `
<div class="metric-value">${item.value}</div>
<div class="metric-label">${item.label}</div>
`;
container.appendChild(metricDiv);
});
}
}
// 작업 통계 업데이트
function updateJobStats() {
const container = document.getElementById('jobStats');
container.innerHTML = '';
if (dashboardData.current_metrics) {
const metrics = dashboardData.current_metrics;
const jobItems = [
{ value: metrics.active_jobs, label: '진행 중인 작업' },
{ value: metrics.queued_jobs, label: '대기 중인 작업' },
{ value: metrics.completed_jobs_today, label: '오늘 완료된 작업' },
{ value: dashboardData.completed_today || 0, label: '총 완료 작업' }
];
jobItems.forEach(item => {
const jobDiv = document.createElement('div');
jobDiv.className = 'metric-item';
jobDiv.innerHTML = `
<div class="metric-value">${item.value}</div>
<div class="metric-label">${item.label}</div>
`;
container.appendChild(jobDiv);
});
}
}
// 활성 작업 업데이트
function updateActiveJobs() {
const container = document.getElementById('activeJobs');
if (dashboardData.active_jobs === 0) {
container.innerHTML = '<div style="text-align: center; color: #64748b; padding: 20px;">현재 진행 중인 작업이 없습니다.</div>';
} else {
container.innerHTML = `<div style="text-align: center; color: #2563eb; padding: 20px;">현재 ${dashboardData.active_jobs}개의 작업이 진행 중입니다.</div>`;
}
}
// 성능 차트 초기화
function initializeChart() {
const canvas = document.getElementById('performanceChart');
const ctx = canvas.getContext('2d');
// 간단한 차트 구현 (실제로는 Chart.js 등 사용 권장)
performanceChart = {
canvas: canvas,
ctx: ctx,
data: []
};
}
// 성능 차트 업데이트
function updatePerformanceChart() {
if (!performanceChart || !dashboardData.recent_metrics) return;
const ctx = performanceChart.ctx;
const canvas = performanceChart.canvas;
// 캔버스 지우기
ctx.clearRect(0, 0, canvas.width, canvas.height);
// 간단한 메모리 사용량 그래프 그리기
const metrics = dashboardData.recent_metrics.slice(-20); // 최근 20개
if (metrics.length === 0) return;
const maxMemory = Math.max(...metrics.map(m => m.metrics.total_memory_usage_mb));
const width = canvas.width;
const height = canvas.height;
ctx.strokeStyle = '#2563eb';
ctx.lineWidth = 2;
ctx.beginPath();
metrics.forEach((metric, index) => {
const x = (index / (metrics.length - 1)) * width;
const y = height - (metric.metrics.total_memory_usage_mb / maxMemory) * height;
if (index === 0) {
ctx.moveTo(x, y);
} else {
ctx.lineTo(x, y);
}
});
ctx.stroke();
// 레이블 추가
ctx.fillStyle = '#64748b';
ctx.font = '12px Arial';
ctx.fillText('메모리 사용량 추이', 10, 20);
ctx.fillText(`현재: ${metrics[metrics.length - 1]?.metrics.total_memory_usage_mb.toFixed(0)}MB`, 10, height - 10);
}
// 헬퍼 함수들
function getStatusText(status) {
const statusMap = {
'ready': '준비완료',
'loading': '로딩중',
'error': '오류',
'unloaded': '로드안됨'
};
return statusMap[status] || status;
}
function formatUptime(seconds) {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
return `${hours}시간 ${minutes}`;
}
// 제어 함수들
async function restartModels() {
if (confirm('AI 모델을 재시작하시겠습니까? 진행 중인 작업이 중단될 수 있습니다.')) {
try {
await fetch('/api/restart-models', { method: 'POST' });
alert('모델 재시작을 시작했습니다. 잠시 후 상태가 업데이트됩니다.');
} catch (error) {
alert('모델 재시작 실패: ' + error.message);
}
}
}
async function clearCache() {
if (confirm('시스템 캐시를 정리하시겠습니까?')) {
try {
await fetch('/api/clear-cache', { method: 'POST' });
alert('캐시 정리가 완료되었습니다.');
} catch (error) {
alert('캐시 정리 실패: ' + error.message);
}
}
}
</script>
</body>
</html>

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>

View File

@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- 서비스의 고유 이름. 보통 역도메인 형식을 사용합니다. -->
<key>Label</key>
<string>com.nllb-translation-system.app</string>
<!-- 실행할 명령어와 인자들 -->
<key>ProgramArguments</key>
<array>
<!-- 1. 가상환경의 파이썬 실행 파일 경로 (절대 경로) -->
<string>/Users/hyungi/Scripts/nllb-translation-system/nllb_env/bin/python</string>
<!-- 2. 실행할 파이썬 스크립트 경로 (절대 경로) -->
<string>/Users/hyungi/Scripts/nllb-translation-system/src/fastapi_with_dashboard.py</string>
</array>
<!-- 스크립트의 작업 디렉토리 설정 (매우 중요) -->
<key>WorkingDirectory</key>
<string>/Users/hyungi/Scripts/nllb-translation-system</string>
<!-- 서비스를 로드할 때 바로 실행 -->
<key>RunAtLoad</key>
<true/>
<!-- 프로세스가 종료되면 자동으로 다시 시작 (무중단 운영의 핵심) -->
<key>KeepAlive</key>
<true/>
<!-- 표준 출력 로그 파일 경로 -->
<key>StandardOutPath</key>
<string>/Users/hyungi/Scripts/nllb-translation-system/logs/service.log</string>
<!-- 표준 에러 로그 파일 경로 -->
<key>StandardErrorPath</key>
<string>/Users/hyungi/Scripts/nllb-translation-system/logs/service_error.log</string>
</dict>
</plist>