346 lines
14 KiB
Python
346 lines
14 KiB
Python
"""backend.engines.doc_translation
|
|
|
|
문서번역 엔진 모듈.
|
|
|
|
MS Word 문서를 업로드하여 한글을 영어로 번역하는 기능을 제공합니다.
|
|
"""
|
|
|
|
import os
|
|
import shutil
|
|
import re
|
|
from datetime import datetime
|
|
from typing import List
|
|
from fastapi import APIRouter, UploadFile, File, HTTPException, Form
|
|
from fastapi.responses import FileResponse
|
|
from docx import Document as DocxDocument
|
|
import openai
|
|
from dotenv import load_dotenv
|
|
|
|
# .env 파일 로드
|
|
load_dotenv()
|
|
|
|
TOOL_ID = "doc_translation"
|
|
|
|
TOOL_INFO = {
|
|
"name": "문서번역",
|
|
"description": "MS Word 문서를 업로드하면 한글을 영어로 번역합니다.",
|
|
"system_prompt": (
|
|
"당신은 전문 번역가입니다. 한국어 문서를 영어로 정확하고 자연스럽게 번역해주세요. "
|
|
"번역할 때는 원문의 의미와 뉘앙스를 최대한 보존하면서도 영어로 자연스럽게 표현해주세요. "
|
|
"대상 문장 다음 줄에 번역 문장을 제공해주세요."
|
|
)
|
|
}
|
|
|
|
# 업로드 디렉토리 설정 - 프로젝트 루트/uploads (개발챗봇과 동일)
|
|
ROOT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir, os.pardir, os.pardir))
|
|
UPLOAD_DIR = os.path.join(ROOT_DIR, "uploads")
|
|
os.makedirs(UPLOAD_DIR, exist_ok=True)
|
|
|
|
# OpenAI API 키 설정
|
|
openai.api_key = os.getenv("OPENAI_API_KEY")
|
|
|
|
def translate_text_with_gpt(text: str) -> str:
|
|
"""ChatGPT를 사용하여 텍스트를 한글에서 영어로 번역 (채팅용)"""
|
|
try:
|
|
client = openai.OpenAI(api_key=os.getenv("OPENAI_API_KEY"))
|
|
|
|
response = client.chat.completions.create(
|
|
model="gpt-5",
|
|
messages=[
|
|
{
|
|
"role": "system",
|
|
"content": TOOL_INFO["system_prompt"]
|
|
},
|
|
{
|
|
"role": "user",
|
|
"content": f"다음 한국어 텍스트를 영어로 번역해주세요:\n\n{text}"
|
|
}
|
|
]
|
|
)
|
|
|
|
return response.choices[0].message.content
|
|
|
|
except Exception as e:
|
|
print(f"번역 오류: {e}")
|
|
return f"번역 오류가 발생했습니다: {str(e)}"
|
|
|
|
# (Ollama 내부 모델 번역 기능 제거)
|
|
|
|
def translate_paragraph(paragraph: str) -> str:
|
|
"""단일 단락을 번역"""
|
|
try:
|
|
client = openai.OpenAI(api_key=os.getenv("OPENAI_API_KEY"))
|
|
|
|
response = client.chat.completions.create(
|
|
model="gpt-5",
|
|
messages=[
|
|
{
|
|
"role": "system",
|
|
"content": "당신은 전문 번역가입니다. 한국어 문단을 영어로 정확하고 자연스럽게 번역해주세요. 번역문만 제공하세요."
|
|
},
|
|
{
|
|
"role": "user",
|
|
"content": f"다음 한국어 문단을 영어로 번역해주세요:\n\n{paragraph}"
|
|
}
|
|
]
|
|
)
|
|
|
|
return response.choices[0].message.content.strip()
|
|
|
|
except Exception as e:
|
|
print(f"단락 번역 오류: {e}")
|
|
return f"[번역 오류: {str(e)}]"
|
|
|
|
def extract_paragraphs_from_docx(file_path: str) -> List[str]:
|
|
"""Word 문서에서 단락별 텍스트 추출"""
|
|
try:
|
|
doc = DocxDocument(file_path)
|
|
paragraphs = []
|
|
for paragraph in doc.paragraphs:
|
|
if paragraph.text.strip():
|
|
paragraphs.append(paragraph.text.strip())
|
|
return paragraphs
|
|
except Exception as e:
|
|
raise HTTPException(status_code=400, detail=f"Word 파일 읽기 오류: {str(e)}")
|
|
|
|
def create_bilingual_docx(original_paragraphs: List[str], translated_paragraphs: List[str], output_path: str):
|
|
"""원본과 번역이 번갈아 나타나는 이중언어 Word 문서 생성"""
|
|
try:
|
|
doc = DocxDocument()
|
|
|
|
# 각 단락별로 원본과 번역을 번갈아 추가
|
|
for i, (original, translated) in enumerate(zip(original_paragraphs, translated_paragraphs)):
|
|
# 원본 단락 추가 (굵은 글씨로)
|
|
original_para = doc.add_paragraph()
|
|
original_run = original_para.add_run(original)
|
|
original_run.bold = True
|
|
|
|
# 번역 단락 추가 (일반 글씨로)
|
|
translated_para = doc.add_paragraph(translated)
|
|
|
|
# 단락 사이에 여백 추가 (마지막 단락이 아닌 경우)
|
|
if i < len(original_paragraphs) - 1:
|
|
doc.add_paragraph("") # 빈 줄 추가
|
|
|
|
doc.save(output_path)
|
|
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=f"이중언어 파일 생성 오류: {str(e)}")
|
|
|
|
def prepare_context(question: str, vector_store=None, model: str = "external") -> str:
|
|
"""채팅용 컨텍스트 준비 (실시간 번역)"""
|
|
if not question.strip():
|
|
return ""
|
|
|
|
# 모델 선택에 따른 번역 처리
|
|
translated = translate_text_with_gpt(question)
|
|
|
|
return translated
|
|
|
|
# FastAPI Router
|
|
router = APIRouter()
|
|
|
|
@router.post("/upload_doc")
|
|
async def upload_document(files: List[UploadFile] = File(...)):
|
|
"""MS Word 문서 업로드 및 번역 처리"""
|
|
if not files:
|
|
raise HTTPException(status_code=400, detail="파일이 업로드되지 않았습니다.")
|
|
|
|
processed_files = []
|
|
|
|
for file in files:
|
|
# MS Word 파일 확인
|
|
if not (file.filename.lower().endswith('.doc') or file.filename.lower().endswith('.docx')):
|
|
raise HTTPException(status_code=400, detail="MS Word 파일(.doc, .docx)만 업로드 가능합니다.")
|
|
|
|
# 파일명 생성: [doc_translation]_admin_YYYYMMDDHHMMSSMMM_원본파일명
|
|
timestamp = datetime.now().strftime('%Y%m%d%H%M%S%f')[:-3] # 밀리초 3자리까지
|
|
original_name = os.path.splitext(file.filename)[0]
|
|
extension = os.path.splitext(file.filename)[1]
|
|
|
|
uploaded_filename = f"[{TOOL_ID}]_admin_{timestamp}_{original_name}{extension}"
|
|
uploaded_path = os.path.join(UPLOAD_DIR, uploaded_filename)
|
|
|
|
# 파일 저장
|
|
try:
|
|
with open(uploaded_path, "wb") as buffer:
|
|
shutil.copyfileobj(file.file, buffer)
|
|
|
|
# 단락별 텍스트 추출
|
|
paragraphs = extract_paragraphs_from_docx(uploaded_path)
|
|
|
|
if not paragraphs:
|
|
raise HTTPException(status_code=400, detail="문서에 번역할 텍스트가 없습니다.")
|
|
|
|
# 각 단락을 개별적으로 번역
|
|
translated_paragraphs = []
|
|
for paragraph in paragraphs:
|
|
translated = translate_paragraph(paragraph)
|
|
translated_paragraphs.append(translated)
|
|
|
|
# 이중언어 문서 저장 (원본 + 번역)
|
|
result_filename = f"[{TOOL_ID}]_admin_{timestamp}_{original_name}_결과{extension}"
|
|
result_path = os.path.join(UPLOAD_DIR, result_filename)
|
|
|
|
create_bilingual_docx(paragraphs, translated_paragraphs, result_path)
|
|
|
|
processed_files.append({
|
|
"original_filename": uploaded_filename,
|
|
"result_filename": result_filename,
|
|
"status": "success"
|
|
})
|
|
|
|
except Exception as e:
|
|
# 업로드된 파일이 있다면 삭제
|
|
if os.path.exists(uploaded_path):
|
|
os.remove(uploaded_path)
|
|
raise HTTPException(status_code=500, detail=f"파일 처리 오류: {str(e)}")
|
|
|
|
return {"status": "success", "files": processed_files}
|
|
|
|
@router.get("/files")
|
|
async def list_files():
|
|
"""업로드된 파일 목록 조회"""
|
|
try:
|
|
files = []
|
|
for filename in os.listdir(UPLOAD_DIR):
|
|
if filename.startswith(f"[{TOOL_ID}]") and not filename.endswith("_결과.doc") and not filename.endswith("_결과.docx"):
|
|
file_path = os.path.join(UPLOAD_DIR, filename)
|
|
stat = os.stat(file_path)
|
|
|
|
# 결과 파일 존재 여부 확인
|
|
# 파일명에서 확장자 분리
|
|
base_name, ext = os.path.splitext(filename)
|
|
result_filename = f"{base_name}_결과{ext}"
|
|
result_exists = os.path.exists(os.path.join(UPLOAD_DIR, result_filename))
|
|
|
|
files.append({
|
|
"filename": filename,
|
|
"upload_time": datetime.fromtimestamp(stat.st_ctime).isoformat(),
|
|
"size": stat.st_size,
|
|
"has_result": result_exists,
|
|
"result_filename": result_filename if result_exists else None
|
|
})
|
|
|
|
# 업로드 시간 순으로 정렬 (최신순)
|
|
files.sort(key=lambda x: x["upload_time"], reverse=True)
|
|
return files
|
|
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=f"파일 목록 조회 오류: {str(e)}")
|
|
|
|
@router.get("/download/{filename}")
|
|
async def download_file(filename: str):
|
|
"""번역된 파일 다운로드"""
|
|
file_path = os.path.join(UPLOAD_DIR, filename)
|
|
|
|
if not os.path.exists(file_path):
|
|
raise HTTPException(status_code=404, detail="파일을 찾을 수 없습니다.")
|
|
|
|
return FileResponse(
|
|
path=file_path,
|
|
filename=filename,
|
|
media_type='application/vnd.openxmlformats-officedocument.wordprocessingml.document'
|
|
)
|
|
|
|
@router.post("/translate_chat")
|
|
async def translate_chat(message: str = Form(...)):
|
|
"""실시간 채팅 번역"""
|
|
if not message.strip():
|
|
raise HTTPException(status_code=400, detail="번역할 메시지가 없습니다.")
|
|
|
|
try:
|
|
translated = translate_text_with_gpt(message)
|
|
return {"translated_text": translated}
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=f"번역 오류: {str(e)}")
|
|
|
|
@router.delete("/files/{filename}")
|
|
async def delete_file(filename: str):
|
|
"""파일 삭제 (서버 저장 파일명으로)"""
|
|
file_path = os.path.join(UPLOAD_DIR, filename)
|
|
|
|
if os.path.exists(file_path):
|
|
os.remove(file_path)
|
|
|
|
# 해당하는 결과 파일도 삭제
|
|
if not filename.endswith("_결과.doc") and not filename.endswith("_결과.docx"):
|
|
base_name, ext = os.path.splitext(filename)
|
|
result_filename = f"{base_name}_결과{ext}"
|
|
result_path = os.path.join(UPLOAD_DIR, result_filename)
|
|
if os.path.exists(result_path):
|
|
os.remove(result_path)
|
|
|
|
return {"status": "success", "message": "파일이 삭제되었습니다."}
|
|
|
|
@router.delete("/delete_by_original_name/{original_filename}")
|
|
async def delete_file_by_original_name(original_filename: str):
|
|
"""파일 삭제 (원본 파일명으로) - 외부 접근용"""
|
|
try:
|
|
# 업로드된 파일 중에서 원본 파일명과 일치하는 파일 찾기
|
|
deleted_files = []
|
|
|
|
for filename in os.listdir(UPLOAD_DIR):
|
|
if filename.startswith(f"[{TOOL_ID}]"):
|
|
# 실제 파일명 추출
|
|
pattern = r'^\[doc_translation\]_admin_\d{14,17}_(.+)$'
|
|
match = re.match(pattern, filename)
|
|
if match and match.group(1) == original_filename:
|
|
# 원본 파일 삭제
|
|
file_path = os.path.join(UPLOAD_DIR, filename)
|
|
if os.path.exists(file_path):
|
|
os.remove(file_path)
|
|
deleted_files.append(filename)
|
|
|
|
# 결과 파일도 삭제
|
|
if not filename.endswith("_결과.doc") and not filename.endswith("_결과.docx"):
|
|
base_name, ext = os.path.splitext(filename)
|
|
result_filename = f"{base_name}_결과{ext}"
|
|
result_path = os.path.join(UPLOAD_DIR, result_filename)
|
|
if os.path.exists(result_path):
|
|
os.remove(result_path)
|
|
deleted_files.append(result_filename)
|
|
|
|
if deleted_files:
|
|
return {"status": "success", "message": f"파일이 삭제되었습니다.", "deleted_files": deleted_files}
|
|
else:
|
|
return {"status": "error", "message": "해당하는 파일을 찾을 수 없습니다."}
|
|
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=f"파일 삭제 오류: {str(e)}")
|
|
|
|
@router.get("/list_files")
|
|
async def list_all_files():
|
|
"""모든 파일 목록 조회 (원본 파일명으로) - 외부 접근용"""
|
|
try:
|
|
files = []
|
|
for filename in os.listdir(UPLOAD_DIR):
|
|
if filename.startswith(f"[{TOOL_ID}]") and not filename.endswith("_결과.doc") and not filename.endswith("_결과.docx"):
|
|
# 실제 파일명 추출
|
|
pattern = r'^\[doc_translation\]_admin_\d{14,17}_(.+)$'
|
|
match = re.match(pattern, filename)
|
|
if match:
|
|
original_name = match.group(1)
|
|
file_path = os.path.join(UPLOAD_DIR, filename)
|
|
stat = os.stat(file_path)
|
|
|
|
# 결과 파일 존재 여부 확인
|
|
base_name, ext = os.path.splitext(filename)
|
|
result_filename = f"{base_name}_결과{ext}"
|
|
result_exists = os.path.exists(os.path.join(UPLOAD_DIR, result_filename))
|
|
|
|
files.append({
|
|
"original_filename": original_name,
|
|
"server_filename": filename,
|
|
"upload_time": datetime.fromtimestamp(stat.st_ctime).isoformat(),
|
|
"size": stat.st_size,
|
|
"has_result": result_exists,
|
|
"result_filename": result_filename if result_exists else None
|
|
})
|
|
|
|
# 업로드 시간 순으로 정렬 (최신순)
|
|
files.sort(key=lambda x: x["upload_time"], reverse=True)
|
|
return {"status": "success", "files": files}
|
|
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=f"파일 목록 조회 오류: {str(e)}") |