849 lines
32 KiB
Python
849 lines
32 KiB
Python
#!/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)
|