Files
AutonetSellCar/backend/app/api/translations.py
AutonetSellCar Deploy 2d7e144a21 Fix duplicate key error in translations auto-extract
- Add tracking set to prevent duplicate entries within same batch
- Refactor to use helper function for consistent duplicate checking

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 20:30:06 +09:00

1013 lines
41 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Translation API with default dictionaries
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from sqlalchemy import or_
from typing import Optional, List
from ..database import get_db
from ..models import Translation
from ..schemas import (
TranslationCreate, TranslationUpdate, TranslationResponse,
TranslationListResponse, TranslationBulkRequest, TranslationBulkResponse,
)
router = APIRouter(prefix="/translations", tags=["translations"])
# Categories for translations
TRANSLATION_CATEGORIES = [
"maker", # Car makers (Hyundai, Kia, etc.)
"model", # Car models (Sonata, K5, etc.)
"fuel", # Fuel types (가솔린, 디젤, etc.)
"transmission", # Transmission types (자동, 수동, etc.)
"color", # Colors (흰색, 검정색, etc.)
"car_name", # Full car names
"general", # General terms
]
# Default translation dictionary for common Korean terms
DEFAULT_TRANSLATIONS = {
# Fuel types (연료)
"fuel": {
"가솔린": {"en": "Gasoline", "mn": "Бензин", "ru": "Бензин"},
"휘발유": {"en": "Gasoline", "mn": "Бензин", "ru": "Бензин"},
"디젤": {"en": "Diesel", "mn": "Дизель", "ru": "Дизель"},
"경유": {"en": "Diesel", "mn": "Дизель", "ru": "Дизель"},
"LPG": {"en": "LPG", "mn": "LPG", "ru": "Газ"},
"가스": {"en": "LPG", "mn": "LPG", "ru": "Газ"},
"전기": {"en": "Electric", "mn": "Цахилгаан", "ru": "Электро"},
"하이브리드": {"en": "Hybrid", "mn": "Хайбрид", "ru": "Гибрид"},
"플러그인하이브리드": {"en": "Plug-in Hybrid", "mn": "Плаг-ин Хайбрид", "ru": "Плагин гибрид"},
"수소": {"en": "Hydrogen", "mn": "Устөрөгч", "ru": "Водород"},
"CNG": {"en": "CNG", "mn": "CNG", "ru": "Метан"},
},
# Transmission types (변속기)
"transmission": {
"자동": {"en": "Automatic", "mn": "Автомат", "ru": "Автомат"},
"오토": {"en": "Automatic", "mn": "Автомат", "ru": "Автомат"},
"수동": {"en": "Manual", "mn": "Механик", "ru": "Механика"},
"스틱": {"en": "Manual", "mn": "Механик", "ru": "Механика"},
"CVT": {"en": "CVT", "mn": "CVT", "ru": "Вариатор"},
"무단변속기": {"en": "CVT", "mn": "CVT", "ru": "Вариатор"},
"DCT": {"en": "DCT", "mn": "DCT", "ru": "Робот"},
"듀얼클러치": {"en": "Dual Clutch", "mn": "Давхар холбоо", "ru": "Робот"},
"세미오토": {"en": "Semi-Auto", "mn": "Хагас автомат", "ru": "Полуавтомат"},
},
# Colors (색상)
"color": {
"흰색": {"en": "White", "mn": "Цагаан", "ru": "Белый"},
"화이트": {"en": "White", "mn": "Цагаан", "ru": "Белый"},
"백색": {"en": "White", "mn": "Цагаан", "ru": "Белый"},
"검정": {"en": "Black", "mn": "Хар", "ru": "Чёрный"},
"검정색": {"en": "Black", "mn": "Хар", "ru": "Чёрный"},
"블랙": {"en": "Black", "mn": "Хар", "ru": "Чёрный"},
"은색": {"en": "Silver", "mn": "Мөнгөлөг", "ru": "Серебристый"},
"실버": {"en": "Silver", "mn": "Мөнгөлөг", "ru": "Серебристый"},
"회색": {"en": "Gray", "mn": "Саарал", "ru": "Серый"},
"그레이": {"en": "Gray", "mn": "Саарал", "ru": "Серый"},
"진회색": {"en": "Dark Gray", "mn": "Бараан саарал", "ru": "Тёмно-серый"},
"빨강": {"en": "Red", "mn": "Улаан", "ru": "Красный"},
"빨간색": {"en": "Red", "mn": "Улаан", "ru": "Красный"},
"레드": {"en": "Red", "mn": "Улаан", "ru": "Красный"},
"파랑": {"en": "Blue", "mn": "Хөх", "ru": "Синий"},
"파란색": {"en": "Blue", "mn": "Хөх", "ru": "Синий"},
"블루": {"en": "Blue", "mn": "Хөх", "ru": "Синий"},
"남색": {"en": "Navy", "mn": "Хар хөх", "ru": "Тёмно-синий"},
"네이비": {"en": "Navy", "mn": "Хар хөх", "ru": "Тёмно-синий"},
"하늘색": {"en": "Sky Blue", "mn": "Тэнгэрийн хөх", "ru": "Голубой"},
"녹색": {"en": "Green", "mn": "Ногоон", "ru": "Зелёный"},
"초록색": {"en": "Green", "mn": "Ногоон", "ru": "Зелёный"},
"그린": {"en": "Green", "mn": "Ногоон", "ru": "Зелёный"},
"노랑": {"en": "Yellow", "mn": "Шар", "ru": "Жёлтый"},
"노란색": {"en": "Yellow", "mn": "Шар", "ru": "Жёлтый"},
"옐로우": {"en": "Yellow", "mn": "Шар", "ru": "Жёлтый"},
"주황색": {"en": "Orange", "mn": "Улбар шар", "ru": "Оранжевый"},
"오렌지": {"en": "Orange", "mn": "Улбар шар", "ru": "Оранжевый"},
"갈색": {"en": "Brown", "mn": "Бор", "ru": "Коричневый"},
"브라운": {"en": "Brown", "mn": "Бор", "ru": "Коричневый"},
"베이지": {"en": "Beige", "mn": "Бээж", "ru": "Бежевый"},
"아이보리": {"en": "Ivory", "mn": "Зүс өнгө", "ru": "Слоновая кость"},
"진주색": {"en": "Pearl", "mn": "Сувдан", "ru": "Перламутр"},
"펄화이트": {"en": "Pearl White", "mn": "Сувдан цагаан", "ru": "Перламутровый белый"},
"보라색": {"en": "Purple", "mn": "Ягаан", "ru": "Фиолетовый"},
"퍼플": {"en": "Purple", "mn": "Ягаан", "ru": "Фиолетовый"},
"분홍색": {"en": "Pink", "mn": "Ягаан", "ru": "Розовый"},
"핑크": {"en": "Pink", "mn": "Ягаан", "ru": "Розовый"},
"와인": {"en": "Wine", "mn": "Дарс өнгө", "ru": "Бордовый"},
"버건디": {"en": "Burgundy", "mn": "Бургунд", "ru": "Бургунди"},
"골드": {"en": "Gold", "mn": "Алтан", "ru": "Золотой"},
"금색": {"en": "Gold", "mn": "Алтан", "ru": "Золотой"},
"샴페인": {"en": "Champagne", "mn": "Шампан", "ru": "Шампань"},
"청색": {"en": "Blue", "mn": "Хөх", "ru": "Синий"},
"연두색": {"en": "Light Green", "mn": "Цайвар ногоон", "ru": "Светло-зелёный"},
"민트": {"en": "Mint", "mn": "Минт", "ru": "Мятный"},
"카키": {"en": "Khaki", "mn": "Хаки", "ru": "Хаки"},
"올리브": {"en": "Olive", "mn": "Чидун ногоон", "ru": "Оливковый"},
"투톤": {"en": "Two-Tone", "mn": "Хоёр өнгө", "ru": "Двухцветный"},
"기타": {"en": "Other", "mn": "Бусад", "ru": "Другой"},
},
# Car makers (제조사)
"maker": {
"현대": {"en": "Hyundai", "mn": "Хёндай", "ru": "Хендай"},
"기아": {"en": "Kia", "mn": "Киа", "ru": "Киа"},
"제네시스": {"en": "Genesis", "mn": "Женезис", "ru": "Дженезис"},
"쉐보레": {"en": "Chevrolet", "mn": "Шевроле", "ru": "Шевроле"},
"르노삼성": {"en": "Renault Samsung", "mn": "Рено Самсунг", "ru": "Рено Самсунг"},
"르노코리아": {"en": "Renault Korea", "mn": "Рено Солонгос", "ru": "Рено Корея"},
"쌍용": {"en": "SsangYong", "mn": "Ссанёнг", "ru": "СсангЙонг"},
"KG모빌리티": {"en": "KG Mobility", "mn": "KG Мобилити", "ru": "КГ Мобилити"},
"BMW": {"en": "BMW", "mn": "BMW", "ru": "БМВ"},
"벤츠": {"en": "Mercedes-Benz", "mn": "Мерседес-Бенз", "ru": "Мерседес"},
"메르세데스벤츠": {"en": "Mercedes-Benz", "mn": "Мерседес-Бенз", "ru": "Мерседес"},
"아우디": {"en": "Audi", "mn": "Ауди", "ru": "Ауди"},
"폭스바겐": {"en": "Volkswagen", "mn": "Фольксваген", "ru": "Фольксваген"},
"토요타": {"en": "Toyota", "mn": "Тойота", "ru": "Тойота"},
"혼다": {"en": "Honda", "mn": "Хонда", "ru": "Хонда"},
"닛산": {"en": "Nissan", "mn": "Ниссан", "ru": "Ниссан"},
"렉서스": {"en": "Lexus", "mn": "Лексус", "ru": "Лексус"},
"인피니티": {"en": "Infiniti", "mn": "Инфинити", "ru": "Инфинити"},
"마쯔다": {"en": "Mazda", "mn": "Мазда", "ru": "Мазда"},
"스바루": {"en": "Subaru", "mn": "Субару", "ru": "Субару"},
"미쓰비시": {"en": "Mitsubishi", "mn": "Мицубиши", "ru": "Митсубиши"},
"포드": {"en": "Ford", "mn": "Форд", "ru": "Форд"},
"링컨": {"en": "Lincoln", "mn": "Линкольн", "ru": "Линкольн"},
"캐딜락": {"en": "Cadillac", "mn": "Кадиллак", "ru": "Кадиллак"},
"지프": {"en": "Jeep", "mn": "Жийп", "ru": "Джип"},
"크라이슬러": {"en": "Chrysler", "mn": "Крайслер", "ru": "Крайслер"},
"테슬라": {"en": "Tesla", "mn": "Тесла", "ru": "Тесла"},
"볼보": {"en": "Volvo", "mn": "Вольво", "ru": "Вольво"},
"포르쉐": {"en": "Porsche", "mn": "Порше", "ru": "Порше"},
"재규어": {"en": "Jaguar", "mn": "Ягуар", "ru": "Ягуар"},
"랜드로버": {"en": "Land Rover", "mn": "Лэнд Ровер", "ru": "Лэнд Ровер"},
"미니": {"en": "Mini", "mn": "Мини", "ru": "Мини"},
"페라리": {"en": "Ferrari", "mn": "Феррари", "ru": "Феррари"},
"람보르기니": {"en": "Lamborghini", "mn": "Ламборгини", "ru": "Ламборгини"},
"벤틀리": {"en": "Bentley", "mn": "Бентли", "ru": "Бентли"},
"롤스로이스": {"en": "Rolls-Royce", "mn": "Роллс-Ройс", "ru": "Роллс-Ройс"},
"마세라티": {"en": "Maserati", "mn": "Мазерати", "ru": "Мазерати"},
"알파로메오": {"en": "Alfa Romeo", "mn": "Альфа Ромео", "ru": "Альфа Ромео"},
"피아트": {"en": "Fiat", "mn": "Фиат", "ru": "Фиат"},
"푸조": {"en": "Peugeot", "mn": "Пежо", "ru": "Пежо"},
"시트로엥": {"en": "Citroen", "mn": "Ситроен", "ru": "Ситроен"},
"르노": {"en": "Renault", "mn": "Рено", "ru": "Рено"},
"스즈키": {"en": "Suzuki", "mn": "Сузуки", "ru": "Сузуки"},
"다이하쓰": {"en": "Daihatsu", "mn": "Дайхатсу", "ru": "Дайхатсу"},
"이스즈": {"en": "Isuzu", "mn": "Исузу", "ru": "Исузу"},
"GMC": {"en": "GMC", "mn": "GMC", "ru": "ДжиЭмСи"},
"대우": {"en": "Daewoo", "mn": "Дэу", "ru": "Дэу"},
"아큐라": {"en": "Acura", "mn": "Акура", "ru": "Акура"},
"뷰익": {"en": "Buick", "mn": "Бюик", "ru": "Бьюик"},
"스마트": {"en": "Smart", "mn": "Смарт", "ru": "Смарт"},
"BYD": {"en": "BYD", "mn": "BYD", "ru": "БИД"},
},
# Car models (차량 모델) - These generally keep their names but with transliteration
"model": {
# Hyundai models
"소나타": {"en": "Sonata", "mn": "Соната", "ru": "Соната"},
"아반떼": {"en": "Avante", "mn": "Аванте", "ru": "Аванте"},
"그랜저": {"en": "Grandeur", "mn": "Грандер", "ru": "Грандер"},
"싼타페": {"en": "Santa Fe", "mn": "Санта Фе", "ru": "Санта Фе"},
"투싼": {"en": "Tucson", "mn": "Туксон", "ru": "Туксон"},
"팰리세이드": {"en": "Palisade", "mn": "Палисейд", "ru": "Палисад"},
"스타리아": {"en": "Staria", "mn": "Стариа", "ru": "Стариа"},
"아이오닉": {"en": "Ioniq", "mn": "Ионик", "ru": "Ионик"},
"코나": {"en": "Kona", "mn": "Кона", "ru": "Кона"},
"베뉴": {"en": "Venue", "mn": "Венью", "ru": "Венью"},
"넥쏘": {"en": "Nexo", "mn": "Нексо", "ru": "Нексо"},
"캐스퍼": {"en": "Casper", "mn": "Каспер", "ru": "Каспер"},
"엘란트라": {"en": "Elantra", "mn": "Элантра", "ru": "Элантра"},
"액센트": {"en": "Accent", "mn": "Акцент", "ru": "Акцент"},
"벨로스터": {"en": "Veloster", "mn": "Велостер", "ru": "Велостер"},
"i30": {"en": "i30", "mn": "i30", "ru": "i30"},
"i40": {"en": "i40", "mn": "i40", "ru": "i40"},
# Kia models
"쏘렌토": {"en": "Sorento", "mn": "Соренто", "ru": "Соренто"},
"스포티지": {"en": "Sportage", "mn": "Спортейж", "ru": "Спортейдж"},
"카니발": {"en": "Carnival", "mn": "Карнивал", "ru": "Карнивал"},
"셀토스": {"en": "Seltos", "mn": "Сельтос", "ru": "Селтос"},
"모하비": {"en": "Mohave", "mn": "Мохаве", "ru": "Мохаве"},
"니로": {"en": "Niro", "mn": "Ниро", "ru": "Ниро"},
"스팅어": {"en": "Stinger", "mn": "Стингер", "ru": "Стингер"},
"레이": {"en": "Ray", "mn": "Рэй", "ru": "Рэй"},
"모닝": {"en": "Morning", "mn": "Морнинг", "ru": "Морнинг"},
"쏘울": {"en": "Soul", "mn": "Соул", "ru": "Соул"},
"K3": {"en": "K3", "mn": "K3", "ru": "K3"},
"K5": {"en": "K5", "mn": "K5", "ru": "K5"},
"K7": {"en": "K7", "mn": "K7", "ru": "K7"},
"K8": {"en": "K8", "mn": "K8", "ru": "K8"},
"K9": {"en": "K9", "mn": "K9", "ru": "K9"},
"EV6": {"en": "EV6", "mn": "EV6", "ru": "EV6"},
"EV9": {"en": "EV9", "mn": "EV9", "ru": "EV9"},
# Genesis models
"G70": {"en": "G70", "mn": "G70", "ru": "G70"},
"G80": {"en": "G80", "mn": "G80", "ru": "G80"},
"G90": {"en": "G90", "mn": "G90", "ru": "G90"},
"GV60": {"en": "GV60", "mn": "GV60", "ru": "GV60"},
"GV70": {"en": "GV70", "mn": "GV70", "ru": "GV70"},
"GV80": {"en": "GV80", "mn": "GV80", "ru": "GV80"},
# SsangYong / KG Mobility models
"렉스턴": {"en": "Rexton", "mn": "Рекстон", "ru": "Рекстон"},
"코란도": {"en": "Korando", "mn": "Корандо", "ru": "Корандо"},
"티볼리": {"en": "Tivoli", "mn": "Тиволи", "ru": "Тиволи"},
"토레스": {"en": "Torres", "mn": "Торрес", "ru": "Торрес"},
"무쏘": {"en": "Musso", "mn": "Муссо", "ru": "Муссо"},
# Chevrolet models
"말리부": {"en": "Malibu", "mn": "Малибу", "ru": "Малибу"},
"트래버스": {"en": "Traverse", "mn": "Траверс", "ru": "Траверс"},
"트랙스": {"en": "Trax", "mn": "Тракс", "ru": "Тракс"},
"이쿼녹스": {"en": "Equinox", "mn": "Эквинокс", "ru": "Эквинокс"},
"스파크": {"en": "Spark", "mn": "Спарк", "ru": "Спарк"},
"볼트": {"en": "Bolt", "mn": "Болт", "ru": "Болт"},
# Renault Samsung models
"SM6": {"en": "SM6", "mn": "SM6", "ru": "SM6"},
"QM6": {"en": "QM6", "mn": "QM6", "ru": "QM6"},
"XM3": {"en": "XM3", "mn": "XM3", "ru": "XM3"},
},
}
@router.get("/categories")
def get_categories():
"""Get available translation categories"""
return TRANSLATION_CATEGORIES
@router.get("", response_model=TranslationListResponse)
def get_translations(
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=200),
category: Optional[str] = None,
search: Optional[str] = None,
db: Session = Depends(get_db),
):
"""Get all translations with pagination and filtering"""
query = db.query(Translation)
if category:
query = query.filter(Translation.category == category)
if search:
search_term = f"%{search}%"
query = query.filter(
or_(
Translation.source_text.ilike(search_term),
Translation.text_en.ilike(search_term),
Translation.text_mn.ilike(search_term),
Translation.text_ru.ilike(search_term),
)
)
total = query.count()
translations = query.order_by(
Translation.category, Translation.source_text
).offset((page - 1) * page_size).limit(page_size).all()
return TranslationListResponse(
total=total,
page=page,
page_size=page_size,
translations=translations
)
@router.get("/{translation_id}", response_model=TranslationResponse)
def get_translation(translation_id: int, db: Session = Depends(get_db)):
"""Get a specific translation by ID"""
translation = db.query(Translation).filter(Translation.id == translation_id).first()
if not translation:
raise HTTPException(status_code=404, detail="Translation not found")
return translation
@router.post("", response_model=TranslationResponse)
def create_translation(data: TranslationCreate, db: Session = Depends(get_db)):
"""Create a new translation"""
# Check if already exists
existing = db.query(Translation).filter(
Translation.source_text == data.source_text,
Translation.category == data.category
).first()
if existing:
raise HTTPException(status_code=400, detail="Translation already exists for this text and category")
translation = Translation(**data.dict())
db.add(translation)
db.commit()
db.refresh(translation)
return translation
@router.put("/{translation_id}", response_model=TranslationResponse)
def update_translation(
translation_id: int,
data: TranslationUpdate,
db: Session = Depends(get_db)
):
"""Update a translation"""
translation = db.query(Translation).filter(Translation.id == translation_id).first()
if not translation:
raise HTTPException(status_code=404, detail="Translation not found")
for key, value in data.dict(exclude_unset=True).items():
setattr(translation, key, value)
db.commit()
db.refresh(translation)
return translation
@router.delete("/{translation_id}")
def delete_translation(translation_id: int, db: Session = Depends(get_db)):
"""Delete a translation"""
translation = db.query(Translation).filter(Translation.id == translation_id).first()
if not translation:
raise HTTPException(status_code=404, detail="Translation not found")
db.delete(translation)
db.commit()
return {"message": "Translation deleted"}
@router.post("/bulk-lookup", response_model=TranslationBulkResponse)
def bulk_lookup(
request: TranslationBulkRequest,
db: Session = Depends(get_db)
):
"""Lookup translations for multiple texts at once"""
if not request.texts:
return TranslationBulkResponse(translations={})
query = db.query(Translation).filter(Translation.source_text.in_(request.texts))
if request.category:
query = query.filter(Translation.category == request.category)
translations_db = query.all()
# Build result dictionary
result = {}
lang_field = f"text_{request.lang}"
for trans in translations_db:
translated = getattr(trans, lang_field, None)
if translated:
result[trans.source_text] = translated
else:
# Fallback to source text if no translation
result[trans.source_text] = trans.source_text
# For texts not found in DB, return original
for text in request.texts:
if text not in result:
result[text] = text
return TranslationBulkResponse(translations=result)
@router.get("/lookup/{text}")
def lookup_single(
text: str,
lang: str = Query("en", description="Target language: en, mn, ru"),
category: Optional[str] = None,
db: Session = Depends(get_db)
):
"""Lookup translation for a single text"""
query = db.query(Translation).filter(Translation.source_text == text)
if category:
query = query.filter(Translation.category == category)
translation = query.first()
if not translation:
return {"source": text, "translated": text}
lang_field = f"text_{lang}"
translated = getattr(translation, lang_field, None)
return {
"source": text,
"translated": translated if translated else text,
"category": translation.category
}
def get_default_translation(source_text: str, category: str) -> dict:
"""Get default translations from the predefined dictionary"""
if category in DEFAULT_TRANSLATIONS:
return DEFAULT_TRANSLATIONS[category].get(source_text, {})
return {}
# Korean year suffix translations
YEAR_SUFFIX_TRANSLATIONS = {
"년형": {"en": "", "mn": " он", "ru": " г."},
"년식": {"en": "", "mn": " он", "ru": " г."},
"": {"en": "", "mn": " он", "ru": " г."},
}
def translate_car_name(car_name: str, lang: str, db) -> str:
"""
Translate a car name like "기아 K5 2024년형" to "Kia K5 2024"
by translating each component separately.
"""
import re
if not car_name:
return car_name
result = car_name
# First, look up all translations we have in DB
all_translations = db.query(Translation).filter(
Translation.category.in_(["maker", "model", "fuel", "transmission", "color"])
).all()
# Build lookup dict
trans_lookup = {}
for t in all_translations:
lang_field = f"text_{lang}"
translated = getattr(t, lang_field, None)
if translated and t.source_text:
trans_lookup[t.source_text] = translated
# Also add from default dictionary
for category in ["maker", "model", "fuel", "transmission", "color"]:
if category in DEFAULT_TRANSLATIONS:
for korean, translations in DEFAULT_TRANSLATIONS[category].items():
if korean not in trans_lookup and lang in translations:
trans_lookup[korean] = translations[lang]
# Replace Korean terms with translations (longest first to avoid partial matches)
sorted_keys = sorted(trans_lookup.keys(), key=len, reverse=True)
for korean in sorted_keys:
if korean in result:
result = result.replace(korean, trans_lookup[korean])
# Handle year format: "2024년형" -> "2024" (en) or "2024 он" (mn) or "2024 г." (ru)
for suffix, translations in YEAR_SUFFIX_TRANSLATIONS.items():
pattern = r'(\d{4})' + re.escape(suffix)
replacement = r'\1' + translations.get(lang, "")
result = re.sub(pattern, replacement, result)
# Clean up extra spaces
result = re.sub(r'\s+', ' ', result).strip()
return result
@router.post("/auto-extract")
def auto_extract_terms(db: Session = Depends(get_db)):
"""
Auto-extract unique terms from cars database that need translation.
This creates translation entries for makers, models, fuels, colors, etc.
Auto-fills with default translations from built-in dictionary.
"""
from ..models import Car, CarMaker, CarModel
added_count = 0
# Track already added entries in this batch to avoid duplicates
added_keys = set()
def add_translation(source_text: str, category: str, text_en: str, text_mn: str, text_ru: str):
nonlocal added_count
key = (source_text, category)
if key in added_keys:
return # Skip duplicate in same batch
existing = db.query(Translation).filter(
Translation.source_text == source_text,
Translation.category == category
).first()
if not existing:
trans = Translation(
source_text=source_text,
category=category,
text_en=text_en,
text_mn=text_mn,
text_ru=text_ru
)
db.add(trans)
added_keys.add(key)
added_count += 1
# Extract makers
makers = db.query(CarMaker).all()
for maker in makers:
if maker.name:
defaults = get_default_translation(maker.name, "maker")
add_translation(
maker.name,
"maker",
defaults.get("en") or maker.name_en or maker.name,
defaults.get("mn") or maker.name,
defaults.get("ru") or maker.name
)
# Extract models
models = db.query(CarModel).all()
for model in models:
if model.name:
defaults = get_default_translation(model.name, "model")
add_translation(
model.name,
"model",
defaults.get("en") or model.name_en or model.name,
defaults.get("mn") or model.name_en or model.name,
defaults.get("ru") or model.name_en or model.name
)
# Extract unique fuels
fuels = db.query(Car.fuel).distinct().filter(Car.fuel.isnot(None)).all()
for (fuel,) in fuels:
if fuel:
defaults = get_default_translation(fuel, "fuel")
add_translation(
fuel,
"fuel",
defaults.get("en") or fuel,
defaults.get("mn") or fuel,
defaults.get("ru") or fuel
)
# Extract unique transmissions
transmissions = db.query(Car.transmission).distinct().filter(Car.transmission.isnot(None)).all()
for (trans_type,) in transmissions:
if trans_type:
defaults = get_default_translation(trans_type, "transmission")
add_translation(
trans_type,
"transmission",
defaults.get("en") or trans_type,
defaults.get("mn") or trans_type,
defaults.get("ru") or trans_type
)
# Extract unique colors
colors = db.query(Car.color).distinct().filter(Car.color.isnot(None)).all()
for (color,) in colors:
if color:
defaults = get_default_translation(color, "color")
add_translation(
color,
"color",
defaults.get("en") or color,
defaults.get("mn") or color,
defaults.get("ru") or color
)
# Extract unique car_names and auto-translate
car_names = db.query(Car.car_name).distinct().filter(Car.car_name.isnot(None)).all()
for (car_name,) in car_names:
if car_name:
key = (car_name, "car_name")
if key not in added_keys:
existing = db.query(Translation).filter(
Translation.source_text == car_name,
Translation.category == "car_name"
).first()
if not existing:
# Auto-translate car name by translating each component
translated_en = translate_car_name(car_name, "en", db)
translated_mn = translate_car_name(car_name, "mn", db)
translated_ru = translate_car_name(car_name, "ru", db)
trans = Translation(
source_text=car_name,
category="car_name",
text_en=translated_en,
text_mn=translated_mn,
text_ru=translated_ru
)
db.add(trans)
added_keys.add(key)
added_count += 1
db.commit()
return {"message": f"Added {added_count} new translation entries with default translations"}
@router.post("/fill-defaults")
def fill_default_translations(db: Session = Depends(get_db)):
"""
Fill in missing translations with default values from the built-in dictionary.
This updates existing entries that have null translations.
"""
updated_count = 0
# Get all translations that have at least one null translation
translations = db.query(Translation).filter(
(Translation.text_en.is_(None)) |
(Translation.text_mn.is_(None)) |
(Translation.text_ru.is_(None))
).all()
for trans in translations:
defaults = get_default_translation(trans.source_text, trans.category)
updated = False
if not trans.text_en and defaults.get("en"):
trans.text_en = defaults["en"]
updated = True
elif not trans.text_en:
# Use source text as fallback for non-Korean languages
trans.text_en = trans.source_text
updated = True
if not trans.text_mn and defaults.get("mn"):
trans.text_mn = defaults["mn"]
updated = True
elif not trans.text_mn:
trans.text_mn = trans.source_text
updated = True
if not trans.text_ru and defaults.get("ru"):
trans.text_ru = defaults["ru"]
updated = True
elif not trans.text_ru:
trans.text_ru = trans.source_text
updated = True
if updated:
updated_count += 1
db.commit()
return {"message": f"Updated {updated_count} translations with default values"}
# AI Auto-Translation Service
import httpx
import json
from typing import Dict, List
import asyncio
# Translation service configuration
TRANSLATION_SERVICE = {
"enabled": True,
"provider": "google", # Options: "google", "deepl", "libre"
"api_key": None, # Set via environment variable
"libre_url": "https://libretranslate.com/translate", # Free option
}
async def translate_text_ai(text: str, source_lang: str, target_lang: str) -> str:
"""
Translate text using AI translation service.
Falls back to original text if translation fails.
"""
if not text or not TRANSLATION_SERVICE["enabled"]:
return text
# Language code mapping
lang_map = {
"ko": "ko",
"en": "en",
"mn": "mn",
"ru": "ru",
}
source = lang_map.get(source_lang, source_lang)
target = lang_map.get(target_lang, target_lang)
try:
async with httpx.AsyncClient(timeout=10.0) as client:
# Using LibreTranslate (free option)
response = await client.post(
TRANSLATION_SERVICE["libre_url"],
json={
"q": text,
"source": source,
"target": target,
"format": "text"
},
headers={"Content-Type": "application/json"}
)
if response.status_code == 200:
result = response.json()
return result.get("translatedText", text)
else:
return text
except Exception as e:
print(f"Translation error: {e}")
return text
def translate_text_sync(text: str, source_lang: str, target_lang: str) -> str:
"""Synchronous wrapper for AI translation"""
try:
return asyncio.run(translate_text_ai(text, source_lang, target_lang))
except RuntimeError:
# Already in an event loop
loop = asyncio.get_event_loop()
if loop.is_running():
# Can't run async in sync context when loop is running
return text
return loop.run_until_complete(translate_text_ai(text, source_lang, target_lang))
from pydantic import BaseModel
from typing import List as PyList
class AutoTranslateRequest(BaseModel):
target_langs: PyList[str] = ["en", "mn", "ru"]
@router.post("/auto-translate/{translation_id}")
def auto_translate_single(
translation_id: int,
request: AutoTranslateRequest = None,
db: Session = Depends(get_db)
):
"""
Auto-translate a single translation entry using AI.
Translates Korean source text to all target languages.
"""
if request is None:
request = AutoTranslateRequest()
translation = db.query(Translation).filter(Translation.id == translation_id).first()
if not translation:
raise HTTPException(status_code=404, detail="Translation not found")
source_text = translation.source_text
translations_result = {}
# First check defaults
defaults = get_default_translation(source_text, translation.category)
# Try to translate to requested languages
if "en" in request.target_langs:
if not translation.text_en or translation.text_en == source_text:
if defaults.get("en"):
translation.text_en = defaults["en"]
else:
translated = translate_text_sync(source_text, "ko", "en")
if translated != source_text:
translation.text_en = translated
translations_result["en"] = translation.text_en or ""
if "mn" in request.target_langs:
if not translation.text_mn or translation.text_mn == source_text:
if defaults.get("mn"):
translation.text_mn = defaults["mn"]
else:
translated = translate_text_sync(source_text, "ko", "mn")
if translated != source_text:
translation.text_mn = translated
translations_result["mn"] = translation.text_mn or ""
if "ru" in request.target_langs:
if not translation.text_ru or translation.text_ru == source_text:
if defaults.get("ru"):
translation.text_ru = defaults["ru"]
else:
translated = translate_text_sync(source_text, "ko", "ru")
if translated != source_text:
translation.text_ru = translated
translations_result["ru"] = translation.text_ru or ""
db.commit()
db.refresh(translation)
return {
"id": translation.id,
"source_text": translation.source_text,
"translations": translations_result,
"message": f"Auto-translated to: {', '.join(request.target_langs)}"
}
class BatchTranslateRequest(BaseModel):
target_langs: PyList[str] = ["en", "mn", "ru"]
category: Optional[str] = None
overwrite_existing: bool = False
@router.post("/auto-translate-batch")
def auto_translate_batch(
request: BatchTranslateRequest = None,
limit: int = Query(100, ge=1, le=500),
db: Session = Depends(get_db)
):
"""
Auto-translate multiple translations that have missing translations.
Limits batch size to avoid API rate limits.
"""
if request is None:
request = BatchTranslateRequest()
# Build query based on options
if request.overwrite_existing:
# Get all translations (optionally filtered by category)
query = db.query(Translation)
else:
# Only get translations with missing values
query = db.query(Translation).filter(
(Translation.text_en.is_(None)) |
(Translation.text_mn.is_(None)) |
(Translation.text_ru.is_(None)) |
(Translation.text_en == Translation.source_text) |
(Translation.text_mn == Translation.source_text) |
(Translation.text_ru == Translation.source_text) |
(Translation.text_en == "") |
(Translation.text_mn == "") |
(Translation.text_ru == "")
)
if request.category:
query = query.filter(Translation.category == request.category)
translations = query.limit(limit).all()
successful = 0
failed = 0
results = []
for trans in translations:
source_text = trans.source_text
try:
updated = False
# First check if we have default translations
defaults = get_default_translation(source_text, trans.category)
# English
if "en" in request.target_langs:
if request.overwrite_existing or not trans.text_en or trans.text_en == source_text or trans.text_en == "":
if defaults.get("en"):
trans.text_en = defaults["en"]
updated = True
else:
translated = translate_text_sync(source_text, "ko", "en")
if translated and translated != source_text:
trans.text_en = translated
updated = True
# Mongolian
if "mn" in request.target_langs:
if request.overwrite_existing or not trans.text_mn or trans.text_mn == source_text or trans.text_mn == "":
if defaults.get("mn"):
trans.text_mn = defaults["mn"]
updated = True
else:
translated = translate_text_sync(source_text, "ko", "mn")
if translated and translated != source_text:
trans.text_mn = translated
updated = True
# Russian
if "ru" in request.target_langs:
if request.overwrite_existing or not trans.text_ru or trans.text_ru == source_text or trans.text_ru == "":
if defaults.get("ru"):
trans.text_ru = defaults["ru"]
updated = True
else:
translated = translate_text_sync(source_text, "ko", "ru")
if translated and translated != source_text:
trans.text_ru = translated
updated = True
if updated:
successful += 1
results.append({
"id": trans.id,
"source_text": source_text,
"success": True
})
else:
results.append({
"id": trans.id,
"source_text": source_text,
"success": True,
"error": "No changes needed"
})
except Exception as e:
failed += 1
results.append({
"id": trans.id,
"source_text": source_text,
"success": False,
"error": str(e)
})
db.commit()
return {
"total_processed": len(translations),
"successful": successful,
"failed": failed,
"results": results
}
class TranslateOnDemandRequest(BaseModel):
text: str
source_lang: str = "ko"
target_lang: str = "en"
@router.post("/translate-on-demand")
def translate_on_demand(
request: TranslateOnDemandRequest,
db: Session = Depends(get_db)
):
"""
Translate a single text on-demand without saving to database.
Useful for dynamic content translation.
"""
text = request.text
target_lang = request.target_lang
if not text:
return {"source_text": "", "translated_text": "", "source_lang": request.source_lang, "target_lang": target_lang}
translated_text = text
# First, check existing translations in DB
trans = db.query(Translation).filter(
Translation.source_text == text
).first()
if trans:
lang_field = f"text_{target_lang}"
translated = getattr(trans, lang_field, None)
if translated and translated != text:
translated_text = translated
else:
# Check default dictionary
for category in DEFAULT_TRANSLATIONS:
if text in DEFAULT_TRANSLATIONS[category]:
if target_lang in DEFAULT_TRANSLATIONS[category][text]:
translated_text = DEFAULT_TRANSLATIONS[category][text][target_lang]
break
# If still not found, try AI translation
if translated_text == text:
translated_text = translate_text_sync(text, request.source_lang, target_lang)
return {
"source_text": text,
"translated_text": translated_text,
"source_lang": request.source_lang,
"target_lang": target_lang
}
@router.get("/stats")
def get_translation_stats(db: Session = Depends(get_db)):
"""Get translation statistics"""
total = db.query(Translation).count()
# Count by category
from sqlalchemy import func
by_category = db.query(
Translation.category,
func.count(Translation.id)
).group_by(Translation.category).all()
# Count missing translations (null or same as source)
missing_en = db.query(Translation).filter(
(Translation.text_en.is_(None)) |
(Translation.text_en == Translation.source_text) |
(Translation.text_en == "")
).count()
missing_mn = db.query(Translation).filter(
(Translation.text_mn.is_(None)) |
(Translation.text_mn == Translation.source_text) |
(Translation.text_mn == "")
).count()
missing_ru = db.query(Translation).filter(
(Translation.text_ru.is_(None)) |
(Translation.text_ru == Translation.source_text) |
(Translation.text_ru == "")
).count()
translated_en = total - missing_en
translated_mn = total - missing_mn
translated_ru = total - missing_ru
return {
"total_entries": total,
"by_category": {cat: count for cat, count in by_category},
"translation_coverage": {
"english": {
"translated": translated_en,
"total": total,
"percentage": round(translated_en / total * 100, 1) if total > 0 else 0
},
"mongolian": {
"translated": translated_mn,
"total": total,
"percentage": round(translated_mn / total * 100, 1) if total > 0 else 0
},
"russian": {
"translated": translated_ru,
"total": total,
"percentage": round(translated_ru / total * 100, 1) if total > 0 else 0
}
}
}