Initial commit - cleaned repository
This commit is contained in:
19
services/pipeline/translator/Dockerfile
Normal file
19
services/pipeline/translator/Dockerfile
Normal file
@ -0,0 +1,19 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies
|
||||
COPY ./translator/requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy shared modules
|
||||
COPY ./shared /app/shared
|
||||
|
||||
# Copy config directory
|
||||
COPY ./config /app/config
|
||||
|
||||
# Copy application code
|
||||
COPY ./translator /app
|
||||
|
||||
# Use multi_translator.py as the main service
|
||||
CMD ["python", "multi_translator.py"]
|
||||
329
services/pipeline/translator/language_sync.py
Normal file
329
services/pipeline/translator/language_sync.py
Normal file
@ -0,0 +1,329 @@
|
||||
"""
|
||||
Language Sync Service
|
||||
기존 기사를 새로운 언어로 번역하는 백그라운드 서비스
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
from typing import List, Dict, Any
|
||||
import httpx
|
||||
from motor.motor_asyncio import AsyncIOMotorClient
|
||||
from datetime import datetime
|
||||
|
||||
# Add parent directory to path for shared module
|
||||
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
# Import from shared module
|
||||
from shared.models import FinalArticle, Subtopic, Entities, NewsReference
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class LanguageSyncService:
|
||||
def __init__(self):
|
||||
self.deepl_api_key = os.getenv("DEEPL_API_KEY", "3abbc796-2515-44a8-972d-22dcf27ab54a")
|
||||
self.deepl_api_url = "https://api.deepl.com/v2/translate"
|
||||
self.mongodb_url = os.getenv("MONGODB_URL", "mongodb://mongodb:27017")
|
||||
self.db_name = os.getenv("DB_NAME", "ai_writer_db")
|
||||
self.db = None
|
||||
self.languages_config = None
|
||||
self.config_path = "/app/config/languages.json"
|
||||
self.sync_batch_size = 10
|
||||
self.sync_delay = 2.0 # 언어 간 지연
|
||||
|
||||
async def load_config(self):
|
||||
"""언어 설정 파일 로드"""
|
||||
try:
|
||||
if os.path.exists(self.config_path):
|
||||
with open(self.config_path, 'r', encoding='utf-8') as f:
|
||||
self.languages_config = json.load(f)
|
||||
logger.info(f"Loaded language config")
|
||||
else:
|
||||
raise FileNotFoundError(f"Config file not found: {self.config_path}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading config: {e}")
|
||||
raise
|
||||
|
||||
async def start(self):
|
||||
"""백그라운드 싱크 서비스 시작"""
|
||||
logger.info("Starting Language Sync Service")
|
||||
|
||||
# 설정 로드
|
||||
await self.load_config()
|
||||
|
||||
# MongoDB 연결
|
||||
client = AsyncIOMotorClient(self.mongodb_url)
|
||||
self.db = client[self.db_name]
|
||||
|
||||
# 주기적으로 싱크 체크 (10분마다)
|
||||
while True:
|
||||
try:
|
||||
await self.sync_missing_translations()
|
||||
await asyncio.sleep(600) # 10분 대기
|
||||
except Exception as e:
|
||||
logger.error(f"Error in sync loop: {e}")
|
||||
await asyncio.sleep(60) # 에러 시 1분 후 재시도
|
||||
|
||||
async def sync_missing_translations(self):
|
||||
"""누락된 번역 싱크"""
|
||||
try:
|
||||
# 활성화된 언어 목록
|
||||
enabled_languages = [
|
||||
lang for lang in self.languages_config["enabled_languages"]
|
||||
if lang["enabled"]
|
||||
]
|
||||
|
||||
if not enabled_languages:
|
||||
logger.info("No enabled languages for sync")
|
||||
return
|
||||
|
||||
# 원본 언어 컬렉션
|
||||
source_collection = self.languages_config["source_language"]["collection"]
|
||||
|
||||
for lang_config in enabled_languages:
|
||||
await self.sync_language(source_collection, lang_config)
|
||||
await asyncio.sleep(self.sync_delay)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in sync_missing_translations: {e}")
|
||||
|
||||
async def sync_language(self, source_collection: str, lang_config: Dict):
|
||||
"""특정 언어로 누락된 기사 번역"""
|
||||
try:
|
||||
target_collection = lang_config["collection"]
|
||||
|
||||
# 번역되지 않은 기사 찾기
|
||||
# 원본에는 있지만 대상 컬렉션에는 없는 기사
|
||||
source_articles = await self.db[source_collection].find(
|
||||
{},
|
||||
{"news_id": 1}
|
||||
).to_list(None)
|
||||
|
||||
source_ids = {article["news_id"] for article in source_articles}
|
||||
|
||||
translated_articles = await self.db[target_collection].find(
|
||||
{},
|
||||
{"news_id": 1}
|
||||
).to_list(None)
|
||||
|
||||
translated_ids = {article["news_id"] for article in translated_articles}
|
||||
|
||||
# 누락된 news_id
|
||||
missing_ids = source_ids - translated_ids
|
||||
|
||||
if not missing_ids:
|
||||
logger.info(f"No missing translations for {lang_config['name']}")
|
||||
return
|
||||
|
||||
logger.info(f"Found {len(missing_ids)} missing translations for {lang_config['name']}")
|
||||
|
||||
# 배치로 처리
|
||||
missing_list = list(missing_ids)
|
||||
for i in range(0, len(missing_list), self.sync_batch_size):
|
||||
batch = missing_list[i:i+self.sync_batch_size]
|
||||
|
||||
for news_id in batch:
|
||||
try:
|
||||
# 원본 기사 조회
|
||||
korean_article = await self.db[source_collection].find_one(
|
||||
{"news_id": news_id}
|
||||
)
|
||||
|
||||
if not korean_article:
|
||||
continue
|
||||
|
||||
# 번역 수행
|
||||
await self.translate_and_save(
|
||||
korean_article,
|
||||
lang_config
|
||||
)
|
||||
|
||||
logger.info(f"Synced article {news_id} to {lang_config['code']}")
|
||||
|
||||
# API 속도 제한
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error translating {news_id} to {lang_config['code']}: {e}")
|
||||
continue
|
||||
|
||||
# 배치 간 지연
|
||||
if i + self.sync_batch_size < len(missing_list):
|
||||
await asyncio.sleep(self.sync_delay)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error syncing language {lang_config['code']}: {e}")
|
||||
|
||||
async def translate_and_save(self, korean_article: Dict, lang_config: Dict):
|
||||
"""기사 번역 및 저장"""
|
||||
try:
|
||||
# 제목 번역
|
||||
translated_title = await self._translate_text(
|
||||
korean_article.get('title', ''),
|
||||
target_lang=lang_config["deepl_code"]
|
||||
)
|
||||
|
||||
# 요약 번역
|
||||
translated_summary = await self._translate_text(
|
||||
korean_article.get('summary', ''),
|
||||
target_lang=lang_config["deepl_code"]
|
||||
)
|
||||
|
||||
# Subtopics 번역
|
||||
translated_subtopics = []
|
||||
for subtopic in korean_article.get('subtopics', []):
|
||||
translated_subtopic_title = await self._translate_text(
|
||||
subtopic.get('title', ''),
|
||||
target_lang=lang_config["deepl_code"]
|
||||
)
|
||||
|
||||
translated_content_list = []
|
||||
for content_para in subtopic.get('content', []):
|
||||
translated_para = await self._translate_text(
|
||||
content_para,
|
||||
target_lang=lang_config["deepl_code"]
|
||||
)
|
||||
translated_content_list.append(translated_para)
|
||||
|
||||
translated_subtopics.append(Subtopic(
|
||||
title=translated_subtopic_title,
|
||||
content=translated_content_list
|
||||
))
|
||||
|
||||
# 카테고리 번역
|
||||
translated_categories = []
|
||||
for category in korean_article.get('categories', []):
|
||||
translated_cat = await self._translate_text(
|
||||
category,
|
||||
target_lang=lang_config["deepl_code"]
|
||||
)
|
||||
translated_categories.append(translated_cat)
|
||||
|
||||
# Entities와 References는 원본 유지
|
||||
entities_data = korean_article.get('entities', {})
|
||||
translated_entities = Entities(**entities_data) if entities_data else Entities()
|
||||
|
||||
references = []
|
||||
for ref_data in korean_article.get('references', []):
|
||||
references.append(NewsReference(**ref_data))
|
||||
|
||||
# 번역된 기사 생성
|
||||
translated_article = FinalArticle(
|
||||
news_id=korean_article.get('news_id'),
|
||||
title=translated_title,
|
||||
summary=translated_summary,
|
||||
subtopics=translated_subtopics,
|
||||
categories=translated_categories,
|
||||
entities=translated_entities,
|
||||
source_keyword=korean_article.get('source_keyword'),
|
||||
source_count=korean_article.get('source_count', 1),
|
||||
references=references,
|
||||
job_id=korean_article.get('job_id'),
|
||||
keyword_id=korean_article.get('keyword_id'),
|
||||
pipeline_stages=korean_article.get('pipeline_stages', []) + ['sync_translation'],
|
||||
processing_time=korean_article.get('processing_time', 0),
|
||||
language=lang_config["code"],
|
||||
ref_news_id=None,
|
||||
rss_guid=korean_article.get('rss_guid'), # RSS GUID 유지
|
||||
image_prompt=korean_article.get('image_prompt'), # 이미지 프롬프트 유지
|
||||
images=korean_article.get('images', []), # 이미지 URL 리스트 유지
|
||||
translated_languages=korean_article.get('translated_languages', []) # 번역 언어 목록 유지
|
||||
)
|
||||
|
||||
# MongoDB에 저장
|
||||
collection_name = lang_config["collection"]
|
||||
result = await self.db[collection_name].insert_one(translated_article.model_dump())
|
||||
|
||||
# 원본 기사에 번역 완료 표시
|
||||
await self.db[self.languages_config["source_language"]["collection"]].update_one(
|
||||
{"news_id": korean_article.get('news_id')},
|
||||
{
|
||||
"$addToSet": {
|
||||
"translated_languages": lang_config["code"]
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
logger.info(f"Synced article to {collection_name}: {result.inserted_id}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in translate_and_save: {e}")
|
||||
raise
|
||||
|
||||
async def _translate_text(self, text: str, target_lang: str = 'EN') -> str:
|
||||
"""DeepL API를 사용한 텍스트 번역"""
|
||||
try:
|
||||
if not text:
|
||||
return ""
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(
|
||||
self.deepl_api_url,
|
||||
data={
|
||||
'auth_key': self.deepl_api_key,
|
||||
'text': text,
|
||||
'target_lang': target_lang,
|
||||
'source_lang': 'KO'
|
||||
},
|
||||
timeout=30
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
result = response.json()
|
||||
return result['translations'][0]['text']
|
||||
else:
|
||||
logger.error(f"DeepL API error: {response.status_code}")
|
||||
return text
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error translating text: {e}")
|
||||
return text
|
||||
|
||||
async def manual_sync(self, language_code: str = None):
|
||||
"""수동 싱크 실행"""
|
||||
logger.info(f"Manual sync requested for language: {language_code or 'all'}")
|
||||
|
||||
await self.load_config()
|
||||
|
||||
client = AsyncIOMotorClient(self.mongodb_url)
|
||||
self.db = client[self.db_name]
|
||||
|
||||
if language_code:
|
||||
# 특정 언어만 싱크
|
||||
lang_config = next(
|
||||
(lang for lang in self.languages_config["enabled_languages"]
|
||||
if lang["code"] == language_code and lang["enabled"]),
|
||||
None
|
||||
)
|
||||
if lang_config:
|
||||
source_collection = self.languages_config["source_language"]["collection"]
|
||||
await self.sync_language(source_collection, lang_config)
|
||||
else:
|
||||
logger.error(f"Language {language_code} not found or not enabled")
|
||||
else:
|
||||
# 모든 활성 언어 싱크
|
||||
await self.sync_missing_translations()
|
||||
|
||||
async def main():
|
||||
"""메인 함수"""
|
||||
service = LanguageSyncService()
|
||||
|
||||
# 명령줄 인수 확인
|
||||
if len(sys.argv) > 1:
|
||||
if sys.argv[1] == "sync":
|
||||
# 수동 싱크 모드
|
||||
language = sys.argv[2] if len(sys.argv) > 2 else None
|
||||
await service.manual_sync(language)
|
||||
else:
|
||||
logger.error(f"Unknown command: {sys.argv[1]}")
|
||||
else:
|
||||
# 백그라운드 서비스 모드
|
||||
try:
|
||||
await service.start()
|
||||
except KeyboardInterrupt:
|
||||
logger.info("Received interrupt signal")
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
320
services/pipeline/translator/multi_translator.py
Normal file
320
services/pipeline/translator/multi_translator.py
Normal file
@ -0,0 +1,320 @@
|
||||
"""
|
||||
Multi-Language Translation Service
|
||||
다국어 번역 서비스 - 설정 기반 다중 언어 지원
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
from typing import List, Dict, Any
|
||||
import httpx
|
||||
import redis.asyncio as redis
|
||||
from motor.motor_asyncio import AsyncIOMotorClient
|
||||
from datetime import datetime
|
||||
|
||||
# Import from shared module
|
||||
from shared.models import PipelineJob, FinalArticle
|
||||
from shared.queue_manager import QueueManager
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class MultiLanguageTranslator:
|
||||
def __init__(self):
|
||||
self.queue_manager = QueueManager(
|
||||
redis_url=os.getenv("REDIS_URL", "redis://redis:6379")
|
||||
)
|
||||
self.deepl_api_key = os.getenv("DEEPL_API_KEY", "3abbc796-2515-44a8-972d-22dcf27ab54a")
|
||||
self.deepl_api_url = "https://api.deepl.com/v2/translate"
|
||||
self.mongodb_url = os.getenv("MONGODB_URL", "mongodb://mongodb:27017")
|
||||
self.db_name = os.getenv("DB_NAME", "ai_writer_db")
|
||||
self.db = None
|
||||
self.languages_config = None
|
||||
self.config_path = "/app/config/languages.json"
|
||||
|
||||
async def load_config(self):
|
||||
"""언어 설정 파일 로드"""
|
||||
try:
|
||||
if os.path.exists(self.config_path):
|
||||
with open(self.config_path, 'r', encoding='utf-8') as f:
|
||||
self.languages_config = json.load(f)
|
||||
else:
|
||||
# 기본 설정 (영어만)
|
||||
self.languages_config = {
|
||||
"enabled_languages": [
|
||||
{
|
||||
"code": "en",
|
||||
"name": "English",
|
||||
"deepl_code": "EN",
|
||||
"collection": "articles_en",
|
||||
"enabled": True
|
||||
}
|
||||
],
|
||||
"source_language": {
|
||||
"code": "ko",
|
||||
"name": "Korean",
|
||||
"collection": "articles_ko"
|
||||
},
|
||||
"translation_settings": {
|
||||
"batch_size": 5,
|
||||
"delay_between_languages": 2.0,
|
||||
"delay_between_articles": 0.5,
|
||||
"max_retries": 3
|
||||
}
|
||||
}
|
||||
logger.info(f"Loaded language config: {len(self.get_enabled_languages())} languages enabled")
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading config: {e}")
|
||||
raise
|
||||
|
||||
def get_enabled_languages(self) -> List[Dict]:
|
||||
"""활성화된 언어 목록 반환"""
|
||||
return [lang for lang in self.languages_config["enabled_languages"] if lang["enabled"]]
|
||||
|
||||
async def start(self):
|
||||
"""워커 시작"""
|
||||
logger.info("Starting Multi-Language Translator Worker")
|
||||
|
||||
# 설정 로드
|
||||
await self.load_config()
|
||||
|
||||
# Redis 연결
|
||||
await self.queue_manager.connect()
|
||||
|
||||
# MongoDB 연결
|
||||
client = AsyncIOMotorClient(self.mongodb_url)
|
||||
self.db = client[self.db_name]
|
||||
|
||||
# DeepL API 키 확인
|
||||
if not self.deepl_api_key:
|
||||
logger.error("DeepL API key not configured")
|
||||
return
|
||||
|
||||
# 메인 처리 루프
|
||||
while True:
|
||||
try:
|
||||
# 큐에서 작업 가져오기
|
||||
job = await self.queue_manager.dequeue('translation', timeout=5)
|
||||
|
||||
if job:
|
||||
await self.process_job(job)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in worker loop: {e}")
|
||||
await asyncio.sleep(1)
|
||||
|
||||
async def process_job(self, job: PipelineJob):
|
||||
"""모든 활성 언어로 번역"""
|
||||
try:
|
||||
logger.info(f"Processing job {job.job_id} for multi-language translation")
|
||||
|
||||
# MongoDB에서 한국어 기사 가져오기
|
||||
news_id = job.data.get('news_id')
|
||||
if not news_id:
|
||||
logger.error(f"No news_id in job {job.job_id}")
|
||||
await self.queue_manager.mark_failed('translation', job, "No news_id")
|
||||
return
|
||||
|
||||
# 원본 컬렉션에서 기사 조회
|
||||
source_collection = self.languages_config["source_language"]["collection"]
|
||||
korean_article = await self.db[source_collection].find_one({"news_id": news_id})
|
||||
|
||||
if not korean_article:
|
||||
logger.error(f"Article {news_id} not found in {source_collection}")
|
||||
await self.queue_manager.mark_failed('translation', job, "Article not found")
|
||||
return
|
||||
|
||||
# 활성화된 모든 언어로 번역
|
||||
enabled_languages = self.get_enabled_languages()
|
||||
settings = self.languages_config["translation_settings"]
|
||||
|
||||
for lang_config in enabled_languages:
|
||||
try:
|
||||
logger.info(f"Translating article {news_id} to {lang_config['name']}")
|
||||
|
||||
# 이미 번역되었는지 확인
|
||||
existing = await self.db[lang_config["collection"]].find_one({"news_id": news_id})
|
||||
if existing:
|
||||
logger.info(f"Article {news_id} already translated to {lang_config['code']}")
|
||||
continue
|
||||
|
||||
# 번역 수행
|
||||
await self.translate_article(
|
||||
korean_article,
|
||||
lang_config,
|
||||
job
|
||||
)
|
||||
|
||||
# 언어 간 지연
|
||||
if settings.get("delay_between_languages"):
|
||||
await asyncio.sleep(settings["delay_between_languages"])
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error translating to {lang_config['code']}: {e}")
|
||||
continue
|
||||
|
||||
# 파이프라인 완료 로그
|
||||
logger.info(f"Translation pipeline completed for news_id: {news_id}")
|
||||
|
||||
# 완료 표시
|
||||
job.stages_completed.append('translation')
|
||||
await self.queue_manager.mark_completed('translation', job.job_id)
|
||||
|
||||
logger.info(f"Multi-language translation completed for job {job.job_id}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing job {job.job_id}: {e}")
|
||||
await self.queue_manager.mark_failed('translation', job, str(e))
|
||||
|
||||
async def translate_article(self, korean_article: Dict, lang_config: Dict, job: PipelineJob):
|
||||
"""특정 언어로 기사 번역"""
|
||||
try:
|
||||
# 제목 번역
|
||||
translated_title = await self._translate_text(
|
||||
korean_article.get('title', ''),
|
||||
target_lang=lang_config["deepl_code"]
|
||||
)
|
||||
|
||||
# 요약 번역
|
||||
translated_summary = await self._translate_text(
|
||||
korean_article.get('summary', ''),
|
||||
target_lang=lang_config["deepl_code"]
|
||||
)
|
||||
|
||||
# Subtopics 번역
|
||||
from shared.models import Subtopic
|
||||
translated_subtopics = []
|
||||
|
||||
for subtopic in korean_article.get('subtopics', []):
|
||||
translated_subtopic_title = await self._translate_text(
|
||||
subtopic.get('title', ''),
|
||||
target_lang=lang_config["deepl_code"]
|
||||
)
|
||||
|
||||
translated_content_list = []
|
||||
for content_para in subtopic.get('content', []):
|
||||
translated_para = await self._translate_text(
|
||||
content_para,
|
||||
target_lang=lang_config["deepl_code"]
|
||||
)
|
||||
translated_content_list.append(translated_para)
|
||||
|
||||
# API 속도 제한
|
||||
settings = self.languages_config["translation_settings"]
|
||||
if settings.get("delay_between_articles"):
|
||||
await asyncio.sleep(settings["delay_between_articles"])
|
||||
|
||||
translated_subtopics.append(Subtopic(
|
||||
title=translated_subtopic_title,
|
||||
content=translated_content_list
|
||||
))
|
||||
|
||||
# 카테고리 번역
|
||||
translated_categories = []
|
||||
for category in korean_article.get('categories', []):
|
||||
translated_cat = await self._translate_text(
|
||||
category,
|
||||
target_lang=lang_config["deepl_code"]
|
||||
)
|
||||
translated_categories.append(translated_cat)
|
||||
|
||||
# Entities와 References는 원본 유지
|
||||
from shared.models import Entities, NewsReference
|
||||
entities_data = korean_article.get('entities', {})
|
||||
translated_entities = Entities(**entities_data) if entities_data else Entities()
|
||||
|
||||
references = []
|
||||
for ref_data in korean_article.get('references', []):
|
||||
references.append(NewsReference(**ref_data))
|
||||
|
||||
# 번역된 기사 생성
|
||||
translated_article = FinalArticle(
|
||||
news_id=korean_article.get('news_id'), # 같은 news_id 사용
|
||||
title=translated_title,
|
||||
summary=translated_summary,
|
||||
subtopics=translated_subtopics,
|
||||
categories=translated_categories,
|
||||
entities=translated_entities,
|
||||
source_keyword=job.keyword if hasattr(job, 'keyword') else korean_article.get('source_keyword'),
|
||||
source_count=korean_article.get('source_count', 1),
|
||||
references=references,
|
||||
job_id=job.job_id,
|
||||
keyword_id=job.keyword_id if hasattr(job, 'keyword_id') else None,
|
||||
pipeline_stages=korean_article.get('pipeline_stages', []) + ['translation'],
|
||||
processing_time=korean_article.get('processing_time', 0),
|
||||
language=lang_config["code"],
|
||||
ref_news_id=None, # 같은 news_id 사용하므로 불필요
|
||||
rss_guid=korean_article.get('rss_guid'), # RSS GUID 유지
|
||||
image_prompt=korean_article.get('image_prompt'), # 이미지 프롬프트 유지
|
||||
images=korean_article.get('images', []), # 이미지 URL 리스트 유지
|
||||
translated_languages=korean_article.get('translated_languages', []) # 번역 언어 목록 유지
|
||||
)
|
||||
|
||||
# MongoDB에 저장
|
||||
collection_name = lang_config["collection"]
|
||||
result = await self.db[collection_name].insert_one(translated_article.model_dump())
|
||||
|
||||
logger.info(f"Article saved to {collection_name} with _id: {result.inserted_id}, language: {lang_config['code']}")
|
||||
|
||||
# 원본 기사에 번역 완료 표시
|
||||
await self.db[self.languages_config["source_language"]["collection"]].update_one(
|
||||
{"news_id": korean_article.get('news_id')},
|
||||
{
|
||||
"$addToSet": {
|
||||
"translated_languages": lang_config["code"]
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error translating article to {lang_config['code']}: {e}")
|
||||
raise
|
||||
|
||||
async def _translate_text(self, text: str, target_lang: str = 'EN') -> str:
|
||||
"""DeepL API를 사용한 텍스트 번역"""
|
||||
try:
|
||||
if not text:
|
||||
return ""
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(
|
||||
self.deepl_api_url,
|
||||
data={
|
||||
'auth_key': self.deepl_api_key,
|
||||
'text': text,
|
||||
'target_lang': target_lang,
|
||||
'source_lang': 'KO'
|
||||
},
|
||||
timeout=30
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
result = response.json()
|
||||
return result['translations'][0]['text']
|
||||
else:
|
||||
logger.error(f"DeepL API error: {response.status_code}")
|
||||
return text # 번역 실패시 원본 반환
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error translating text: {e}")
|
||||
return text # 번역 실패시 원본 반환
|
||||
|
||||
async def stop(self):
|
||||
"""워커 중지"""
|
||||
await self.queue_manager.disconnect()
|
||||
logger.info("Multi-Language Translator Worker stopped")
|
||||
|
||||
async def main():
|
||||
"""메인 함수"""
|
||||
worker = MultiLanguageTranslator()
|
||||
|
||||
try:
|
||||
await worker.start()
|
||||
except KeyboardInterrupt:
|
||||
logger.info("Received interrupt signal")
|
||||
finally:
|
||||
await worker.stop()
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
5
services/pipeline/translator/requirements.txt
Normal file
5
services/pipeline/translator/requirements.txt
Normal file
@ -0,0 +1,5 @@
|
||||
httpx==0.25.0
|
||||
redis[hiredis]==5.0.1
|
||||
pydantic==2.5.0
|
||||
motor==3.1.1
|
||||
pymongo==4.3.3
|
||||
230
services/pipeline/translator/translator.py
Normal file
230
services/pipeline/translator/translator.py
Normal file
@ -0,0 +1,230 @@
|
||||
"""
|
||||
Translation Service
|
||||
DeepL API를 사용한 번역 서비스
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from typing import List, Dict, Any
|
||||
import httpx
|
||||
from motor.motor_asyncio import AsyncIOMotorClient
|
||||
from datetime import datetime
|
||||
|
||||
# Import from shared module
|
||||
from shared.models import PipelineJob, FinalArticle
|
||||
from shared.queue_manager import QueueManager
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class TranslatorWorker:
|
||||
def __init__(self):
|
||||
self.queue_manager = QueueManager(
|
||||
redis_url=os.getenv("REDIS_URL", "redis://redis:6379")
|
||||
)
|
||||
self.deepl_api_key = os.getenv("DEEPL_API_KEY", "3abbc796-2515-44a8-972d-22dcf27ab54a")
|
||||
# DeepL Pro API 엔드포인트 사용
|
||||
self.deepl_api_url = "https://api.deepl.com/v2/translate"
|
||||
self.mongodb_url = os.getenv("MONGODB_URL", "mongodb://mongodb:27017")
|
||||
self.db_name = os.getenv("DB_NAME", "ai_writer_db")
|
||||
self.db = None
|
||||
|
||||
async def start(self):
|
||||
"""워커 시작"""
|
||||
logger.info("Starting Translator Worker")
|
||||
|
||||
# Redis 연결
|
||||
await self.queue_manager.connect()
|
||||
|
||||
# MongoDB 연결
|
||||
client = AsyncIOMotorClient(self.mongodb_url)
|
||||
self.db = client[self.db_name]
|
||||
|
||||
# DeepL API 키 확인
|
||||
if not self.deepl_api_key:
|
||||
logger.error("DeepL API key not configured")
|
||||
return
|
||||
|
||||
# 메인 처리 루프
|
||||
while True:
|
||||
try:
|
||||
# 큐에서 작업 가져오기
|
||||
job = await self.queue_manager.dequeue('translation', timeout=5)
|
||||
|
||||
if job:
|
||||
await self.process_job(job)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in worker loop: {e}")
|
||||
await asyncio.sleep(1)
|
||||
|
||||
async def process_job(self, job: PipelineJob):
|
||||
"""영어 버전 기사 생성 및 저장"""
|
||||
try:
|
||||
logger.info(f"Processing job {job.job_id} for translation")
|
||||
|
||||
# MongoDB에서 한국어 기사 가져오기
|
||||
news_id = job.data.get('news_id')
|
||||
if not news_id:
|
||||
logger.error(f"No news_id in job {job.job_id}")
|
||||
await self.queue_manager.mark_failed('translation', job, "No news_id")
|
||||
return
|
||||
|
||||
# MongoDB에서 한국어 기사 조회 (articles_ko)
|
||||
korean_article = await self.db.articles_ko.find_one({"news_id": news_id})
|
||||
if not korean_article:
|
||||
logger.error(f"Article {news_id} not found in MongoDB")
|
||||
await self.queue_manager.mark_failed('translation', job, "Article not found")
|
||||
return
|
||||
|
||||
# 영어로 번역
|
||||
translated_title = await self._translate_text(
|
||||
korean_article.get('title', ''),
|
||||
target_lang='EN'
|
||||
)
|
||||
|
||||
translated_summary = await self._translate_text(
|
||||
korean_article.get('summary', ''),
|
||||
target_lang='EN'
|
||||
)
|
||||
|
||||
# Subtopics 번역
|
||||
from shared.models import Subtopic
|
||||
translated_subtopics = []
|
||||
for subtopic in korean_article.get('subtopics', []):
|
||||
translated_subtopic_title = await self._translate_text(
|
||||
subtopic.get('title', ''),
|
||||
target_lang='EN'
|
||||
)
|
||||
|
||||
translated_content_list = []
|
||||
for content_para in subtopic.get('content', []):
|
||||
translated_para = await self._translate_text(
|
||||
content_para,
|
||||
target_lang='EN'
|
||||
)
|
||||
translated_content_list.append(translated_para)
|
||||
await asyncio.sleep(0.2) # API 속도 제한
|
||||
|
||||
translated_subtopics.append(Subtopic(
|
||||
title=translated_subtopic_title,
|
||||
content=translated_content_list
|
||||
))
|
||||
|
||||
# 카테고리 번역
|
||||
translated_categories = []
|
||||
for category in korean_article.get('categories', []):
|
||||
translated_cat = await self._translate_text(category, target_lang='EN')
|
||||
translated_categories.append(translated_cat)
|
||||
await asyncio.sleep(0.2) # API 속도 제한
|
||||
|
||||
# Entities 번역 (선택적)
|
||||
from shared.models import Entities
|
||||
entities_data = korean_article.get('entities', {})
|
||||
translated_entities = Entities(
|
||||
people=entities_data.get('people', []), # 인명은 번역하지 않음
|
||||
organizations=entities_data.get('organizations', []), # 조직명은 번역하지 않음
|
||||
groups=entities_data.get('groups', []),
|
||||
countries=entities_data.get('countries', []),
|
||||
events=entities_data.get('events', [])
|
||||
)
|
||||
|
||||
# 레퍼런스 가져오기 (번역하지 않음)
|
||||
from shared.models import NewsReference
|
||||
references = []
|
||||
for ref_data in korean_article.get('references', []):
|
||||
references.append(NewsReference(**ref_data))
|
||||
|
||||
# 영어 버전 기사 생성 - 같은 news_id 사용
|
||||
english_article = FinalArticle(
|
||||
news_id=news_id, # 원본과 같은 news_id 사용
|
||||
title=translated_title,
|
||||
summary=translated_summary,
|
||||
subtopics=translated_subtopics,
|
||||
categories=translated_categories,
|
||||
entities=translated_entities,
|
||||
source_keyword=job.keyword,
|
||||
source_count=korean_article.get('source_count', 1),
|
||||
references=references, # 원본 레퍼런스 그대로 사용
|
||||
job_id=job.job_id,
|
||||
keyword_id=job.keyword_id,
|
||||
pipeline_stages=job.stages_completed.copy() + ['translation'],
|
||||
processing_time=korean_article.get('processing_time', 0),
|
||||
language='en', # 영어
|
||||
ref_news_id=None # 같은 news_id를 사용하므로 ref 불필요
|
||||
)
|
||||
|
||||
# MongoDB에 영어 버전 저장 (articles_en)
|
||||
result = await self.db.articles_en.insert_one(english_article.model_dump())
|
||||
english_article_id = str(result.inserted_id)
|
||||
|
||||
logger.info(f"English article saved with _id: {english_article_id}, news_id: {news_id}, language: en")
|
||||
|
||||
# 원본 한국어 기사 업데이트 - 번역 완료 표시
|
||||
await self.db.articles_ko.update_one(
|
||||
{"news_id": news_id},
|
||||
{
|
||||
"$addToSet": {
|
||||
"pipeline_stages": "translation"
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
# 완료 표시
|
||||
job.stages_completed.append('translation')
|
||||
await self.queue_manager.mark_completed('translation', job.job_id)
|
||||
|
||||
logger.info(f"Translation completed for job {job.job_id}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing job {job.job_id}: {e}")
|
||||
await self.queue_manager.mark_failed('translation', job, str(e))
|
||||
|
||||
async def _translate_text(self, text: str, target_lang: str = 'EN') -> str:
|
||||
"""DeepL API를 사용한 텍스트 번역"""
|
||||
try:
|
||||
if not text:
|
||||
return ""
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(
|
||||
self.deepl_api_url,
|
||||
data={
|
||||
'auth_key': self.deepl_api_key,
|
||||
'text': text,
|
||||
'target_lang': target_lang,
|
||||
'source_lang': 'KO'
|
||||
},
|
||||
timeout=30
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
result = response.json()
|
||||
return result['translations'][0]['text']
|
||||
else:
|
||||
logger.error(f"DeepL API error: {response.status_code}")
|
||||
return text # 번역 실패시 원본 반환
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error translating text: {e}")
|
||||
return text # 번역 실패시 원본 반환
|
||||
|
||||
async def stop(self):
|
||||
"""워커 중지"""
|
||||
await self.queue_manager.disconnect()
|
||||
logger.info("Translator Worker stopped")
|
||||
|
||||
async def main():
|
||||
"""메인 함수"""
|
||||
worker = TranslatorWorker()
|
||||
|
||||
try:
|
||||
await worker.start()
|
||||
except KeyboardInterrupt:
|
||||
logger.info("Received interrupt signal")
|
||||
finally:
|
||||
await worker.stop()
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
Reference in New Issue
Block a user