Initial working translator implementation

This commit is contained in:
burnintuna 2026-07-30 14:53:36 +09:00
commit 9439858b11
35 changed files with 5365 additions and 0 deletions

1
backend/__init__.py Normal file
View file

@ -0,0 +1 @@
# LLM Translator Backend Package

85
backend/auth.py Normal file
View file

@ -0,0 +1,85 @@
"""JWT-based authentication with config-file user management."""
import os
from datetime import datetime, timedelta, timezone
from pathlib import Path
import bcrypt
import jwt
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
# ── Config ────────────────────────────────────────────
JWT_SECRET_KEY = os.getenv("JWT_SECRET_KEY", "dev-secret-key-change-in-production")
JWT_ALGORITHM = "HS256"
JWT_EXPIRE_HOURS = int(os.getenv("JWT_EXPIRE_HOURS", "24"))
security = HTTPBearer()
# ── User Loading ───────────────────────────────────────
def _load_users_file():
"""Load users from config file. Auto-hash plain-text passwords on first run."""
import json as j
config_path = os.getenv(
"USERS_CONFIG_PATH", str(Path(__file__).parent.parent / "config" / "users.json")
)
with open(config_path, encoding="utf-8") as f:
users_raw = j.load(f)
# Migrate plain-text passwords to bcrypt hashes (one-time on startup)
migrated = False
for user in users_raw:
if "password_plain" in user and "password" not in user:
pw_bytes = user.pop("password_plain").encode("utf-8")
user["password"] = bcrypt.hashpw(pw_bytes, bcrypt.gensalt()).decode("utf-8")
migrated = True
if migrated:
with open(config_path, "w", encoding="utf-8") as f:
j.dump(users_raw, f, ensure_ascii=False, indent=2)
return users_raw
_users_db = _load_users_file()
def get_user_by_username(username: str):
for u in _users_db:
if u["id"] == username:
return u
return None
# ── Token Helpers ─────────────────────────────────────
def create_access_token(data: dict) -> str:
to_encode = data.copy()
expire = datetime.now(timezone.utc) + timedelta(hours=JWT_EXPIRE_HOURS)
to_encode.update({"exp": expire})
return jwt.encode(to_encode, JWT_SECRET_KEY, algorithm=JWT_ALGORITHM)
def decode_access_token(token: str) -> dict | None:
try:
return jwt.decode(token, JWT_SECRET_KEY, algorithms=[JWT_ALGORITHM])
except jwt.ExpiredSignatureError:
return None
except jwt.InvalidTokenError:
return None
# ── Dependency ───────────────────────────────────────
async def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)) -> str:
"""Extract and validate JWT, returning the user_id."""
payload = decode_access_token(credentials.credentials)
if payload is None or "user_id" not in payload:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="유효하지 않거나 만료된 토큰입니다",
)
return payload["user_id"]

88
backend/llm_client.py Normal file
View file

@ -0,0 +1,88 @@
"""OpenAI-compatible LLM client for multi-server support."""
import os
from pathlib import Path
from openai import AsyncOpenAI
from backend.models import LLMConfig
# ── Server Config Loading ─────────────────────────────
def _load_llm_configs() -> list[LLMConfig]:
config_path = os.getenv(
"LLMS_CONFIG_PATH", str(Path(__file__).parent.parent / "config" / "llms.json")
)
import json
with open(config_path, encoding="utf-8") as f:
data = json.load(f)
return [LLMConfig(**item) for item in data]
_llm_configs: list[LLMConfig] = []
_clients: dict[str, AsyncOpenAI] = {} # alias -> client instance
def get_llm_configs() -> list[LLMConfig]:
"""Return all LLM configurations."""
return _llm_configs
def get_client_for_alias(alias: str) -> tuple[AsyncOpenAI, LLMConfig]:
"""Get the AsyncOpenAI client and config for a given alias.
Creates a lazily cached client per alias.
"""
if alias not in _clients:
cfg = None
for c in _llm_configs:
if c.alias == alias:
cfg = c
break
if cfg is None:
raise ValueError(f"Unknown LLM alias: {alias}")
client = AsyncOpenAI(
api_key=cfg.api_key,
base_url=cfg.base_url,
timeout=120.0,
)
_clients[alias] = client
return _clients[alias], next(c for c in _llm_configs if c.alias == alias)
async def chat_complete(
alias: str,
system_prompt: str,
user_content: str,
temperature: float = 0.3,
) -> str:
"""Send a single-turn chat completion and return the assistant message."""
client, cfg = get_client_for_alias(alias)
response = await client.chat.completions.create(
model=cfg.model,
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_content},
],
temperature=temperature,
)
content = response.choices[0].message.content or ""
return content.strip()
def get_context_size(alias: str) -> int | None:
"""Get the context window size for a given alias."""
for c in _llm_configs:
if c.alias == alias:
return c.context_size
return None
# ── Initialize on import ──────────────────────────────
_llm_configs = _load_llm_configs()

286
backend/main.py Normal file
View file

@ -0,0 +1,286 @@
"""FastAPI application — main entry point for the LLM Translator backend."""
import os
from pathlib import Path
from fastapi import Depends, FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from backend import auth as auth_mod
from backend.auth import get_current_user
from backend.llm_client import (
chat_complete,
get_llm_configs,
get_context_size,
)
from backend.models import (
CreateSessionRequest,
LoginRequest,
Phase1Result,
Phase2ConfirmRequest,
PhaseUpdateRequest,
ProperNoun,
SessionResponse,
TokenResponse,
UserInfo,
TranslationSession,
)
from backend.prompts import (
SYSTEM_PROMPT_PHASE1,
SYSTEM_PROMPT_PHASE3,
build_phase1_user_prompt,
build_phase3_user_prompt,
build_phase4_user_prompt,
)
from backend.sessions import session_store
# ── App Setup ────────────────────────────────────────
app = FastAPI(title="LLM Translator", version="0.1.0")
# CORS (frontend same-origin by default, allow dev origins)
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:5173", "http://localhost:8000"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# ── Auth Routes ───────────────────────────────────────
@app.post("/api/auth/login")
async def login(req: LoginRequest):
import bcrypt as _bcrypt
user = auth_mod.get_user_by_username(req.id)
if user is None or not _bcrypt.checkpw(
req.password.encode("utf-8"), user["password"].encode("utf-8")
):
raise HTTPException(status_code=401, detail="ID 또는 비밀번호가 틀렸습니다")
token = auth_mod.create_access_token({"user_id": user["id"]})
return TokenResponse(access_token=token)
@app.get("/api/auth/me")
async def me(user_id: str = Depends(get_current_user)):
return UserInfo(user_id=user_id)
# ── LLM Config Routes ────────────────────────────────
@app.get("/api/models")
async def list_models():
"""Return available LLM model configurations."""
configs = get_llm_configs()
return [
{
"alias": c.alias,
"model": c.model,
"context_size": c.context_size,
}
for c in configs
]
# ── Session Routes ───────────────────────────────────
@app.post("/api/translate/create")
async def create_session(req: CreateSessionRequest):
session_data = TranslationSession(
source_text=req.source_text,
source_language=req.source_language,
target_language=req.target_language,
model_phase1=req.model_phase1,
model_phase2=req.model_phase2,
model_phase3=req.model_phase3,
model_phase4=req.model_phase4,
)
session_id = session_store.create(session_data)
return {"session_id": session_id}
@app.get("/api/sessions/{session_id}")
async def get_session(session_id: str):
data = session_store.get(session_id)
if data is None:
raise HTTPException(status_code=404, detail="세션을 찾을 수 없습니다")
return SessionResponse(session_id=session_id, data=data).model_dump()
@app.delete("/api/sessions/{session_id}")
async def delete_session(session_id: str):
deleted = session_store.delete(session_id)
if not deleted:
raise HTTPException(status_code=404, detail="세션을 찾을 수 없습니다")
return {"deleted": True}
@app.patch("/api/sessions/{session_id}")
async def update_session(session_id: str, req: PhaseUpdateRequest):
data = req.model_dump(exclude_none=True)
updated = session_store.update(session_id, data)
if updated is None:
raise HTTPException(status_code=404, detail="세션을 찾을 수 없습니다")
return SessionResponse(session_id=session_id, data=updated).model_dump()
# ── Phase Execution Routes ───────────────────────────
@app.post("/api/translate/{session_id}/phase1")
async def run_phase1(session_id: str):
"""Run Phase 1: rough translation + proper noun extraction + summary + style analysis."""
session = session_store.get(session_id)
if session is None:
raise HTTPException(status_code=404, detail="세션을 찾을 수 없습니다")
alias = session.model_phase1
if not alias:
raise HTTPException(status_code=400, detail="Phase 1 LLM 모델을 선택하세요")
# Context size check (rough: source text tokens ~ len/2 for Korean)
ctx_size = get_context_size(alias)
if ctx_size and len(session.source_text) > ctx_size * 0.7:
raise HTTPException(
status_code=400,
detail=f"원문이 너무 깁니다 (최대 {ctx_size} 컨텍스트의 70% 이내로 줄여주세요)",
)
user_prompt = build_phase1_user_prompt(session.source_text, session.target_language)
raw_response = await chat_complete(alias, SYSTEM_PROMPT_PHASE1, user_prompt)
# Parse JSON response — extract from code blocks if wrapped
import json
cleaned = _extract_json(raw_response)
parsed = json.loads(cleaned)
result = Phase1Result(
translated=parsed.get("translated", ""),
proper_nouns=[ProperNoun(**pn) for pn in parsed.get("proper_nouns", [])],
summary=parsed.get("summary", ""),
style=parsed.get("style", ""),
)
# Update session with Phase 1 results and initialize Phase 2 data
update_data = {
"phase1_result": result.model_dump(),
"phase2_proper_nouns": [pn.model_dump() for pn in result.proper_nouns],
"phase2_style": result.style,
}
updated = session_store.update(session_id, update_data)
if updated is None:
raise HTTPException(status_code=404, detail="세션을 찾을 수 없습니다")
return SessionResponse(session_id=session_id, data=updated).model_dump()
@app.post("/api/translate/{session_id}/phase2")
async def run_phase2(session_id: str, req: Phase2ConfirmRequest):
"""Phase 2: user confirms or edits proper nouns and style."""
session = session_store.get(session_id)
if session is None:
raise HTTPException(status_code=404, detail="세션을 찾을 수 없습니다")
update_data = {
"phase2_proper_nouns": [pn.model_dump() for pn in req.proper_nouns],
"phase2_style": req.style,
}
updated = session_store.update(session_id, update_data)
if updated is None:
raise HTTPException(status_code=404, detail="세션을 찾을 수 없습니다")
return SessionResponse(session_id=session_id, data=updated).model_dump()
@app.post("/api/translate/{session_id}/phase3")
async def run_phase3(session_id: str):
"""Run Phase 3: re-translation with proper noun and style constraints."""
session = session_store.get(session_id)
if session is None:
raise HTTPException(status_code=404, detail="세션을 찾을 수 없습니다")
if not session.phase1_result:
raise HTTPException(status_code=400, detail="먼저 Phase 1을 실행하세요")
alias = session.model_phase3 or session.model_phase1
user_prompt = build_phase3_user_prompt(
phase1_translated=session.phase1_result.translated,
proper_nouns=[pn.model_dump() for pn in session.phase2_proper_nouns],
style=session.phase2_style or (session.phase1_result.style if session.phase1_result else ""),
target_language=session.target_language,
)
result_text = await chat_complete(alias, SYSTEM_PROMPT_PHASE3, user_prompt)
update_data = {"phase3_result": result_text}
updated = session_store.update(session_id, update_data)
if updated is None:
raise HTTPException(status_code=404, detail="세션을 찾을 수 없습니다")
return SessionResponse(session_id=session_id, data=updated).model_dump()
@app.post("/api/translate/{session_id}/phase4")
async def run_phase4(session_id: str):
"""Run Phase 4: polish for readability."""
session = session_store.get(session_id)
if session is None:
raise HTTPException(status_code=404, detail="세션을 찾을 수 없습니다")
if not session.phase3_result:
raise HTTPException(status_code=400, detail="먼저 Phase 3을 실행하세요")
alias = session.model_phase4 or session.model_phase3 or session.model_phase1
user_prompt = build_phase4_user_prompt(
phase3_result=session.phase3_result,
proper_nouns=[pn.model_dump() for pn in session.phase2_proper_nouns],
style=session.phase2_style or (session.phase1_result.style if session.phase1_result else ""),
)
# Patch target_language into system prompt
sys_prompt = SYSTEM_PROMPT_PHASE4.replace("{target_language}", session.target_language)
result_text = await chat_complete(alias, sys_prompt, user_prompt)
update_data = {"phase4_result": result_text}
updated = session_store.update(session_id, update_data)
if updated is None:
raise HTTPException(status_code=404, detail="세션을 찾을 수 없습니다")
return SessionResponse(session_id=session_id, data=updated).model_dump()
# ── Helpers ───────────────────────────────────────────
def _extract_json(text: str) -> str:
"""Extract JSON from LLM response, handling markdown code blocks."""
text = text.strip()
# Remove markdown json/code block wrapper
if "```" in text:
parts = text.split("```")
for part in parts:
part = part.strip()
if part.startswith("json"):
part = part[4:].strip()
if part.startswith("{"):
return part
return text
# ── Static File Serving (Frontend) ───────────────────
frontend_dist = os.getenv(
"FRONTEND_DIST_PATH", str(Path(__file__).parent.parent / "frontend" / "dist")
)
if Path(frontend_dist).exists():
app.mount("/", StaticFiles(directory=frontend_dist, html=True), name="frontend")
else:
# Dev mode: serve a simple HTML page
@app.get("/")
async def dev_root():
return {"message": "LLM Translator — 프론트엔드를 빌드하세요 (cd frontend && npm run build)"}

99
backend/models.py Normal file
View file

@ -0,0 +1,99 @@
"""Pydantic request/response models for the API."""
from pydantic import BaseModel, Field
# ── Auth ───────────────────────────────────────────────
class LoginRequest(BaseModel):
id: str
password: str
class TokenResponse(BaseModel):
access_token: str
token_type: str = "bearer"
class UserInfo(BaseModel):
user_id: str
# ── LLM Config ───────────────────────────────────────
class LLMConfig(BaseModel):
alias: str
base_url: str
api_key: str
model: str
context_size: int
# ── Translation Session ────────────────────────────────
class ProperNoun(BaseModel):
original: str # 원문 고유명사
suggested: str # 제안 번역
final: str = "" # 사용자 최종 결정 (Phase 2)
class Phase1Result(BaseModel):
translated: str # 초벌 번역 결과
proper_nouns: list[ProperNoun] # 추출된 고유명사 리스트
summary: str # 내용 요약
style: str # 문체 분석 결과
class TranslationSession(BaseModel):
"""Full translation session state."""
source_text: str = "" # 원문
source_language: str = "auto" # 출발어 (auto = 자동판단)
target_language: str = "한국어" # 도착어
model_phase1: str = "" # Phase 1 LLM 모델 alias
model_phase2: str = "" # Phase 2 LLM 모델 alias
model_phase3: str = "" # Phase 3 LLM 모델 alias
model_phase4: str = "" # Phase 4 LLM 모델 alias
phase1_result: Phase1Result | None = None # 초벌 번역 결과
phase2_proper_nouns: list[ProperNoun] = [] # Phase 2에서 최종 확정된 고유명사
phase2_style: str = "" # Phase 2에서 최종 확정된 문체
phase3_result: str = "" # 재번역 결과
phase4_result: str = "" # 마무리 다듬기 결과
# ── API Requests ───────────────────────────────────────
class CreateSessionRequest(BaseModel):
source_text: str
source_language: str = "auto"
target_language: str = "한국어"
model_phase1: str = ""
model_phase2: str = ""
model_phase3: str = ""
model_phase4: str = ""
class PhaseUpdateRequest(BaseModel):
"""Partial update for a specific phase."""
source_text: str | None = None
source_language: str | None = None
target_language: str | None = None
model_phase1: str | None = None
model_phase2: str | None = None
model_phase3: str | None = None
model_phase4: str | None = None
class Phase2ConfirmRequest(BaseModel):
"""User confirms or edits proper nouns and style for Phase 2."""
proper_nouns: list[ProperNoun]
style: str
# ── API Responses ──────────────────────────────────────
class SessionResponse(BaseModel):
session_id: str
data: TranslationSession

113
backend/prompts.py Normal file
View file

@ -0,0 +1,113 @@
"""Phase 1~4 prompt templates for the translation pipeline."""
# ── Phase 1: 초벌 번역 + 고유명사 추출 + 요약 + 문체 분석 ─
SYSTEM_PROMPT_PHASE1 = """\
당신은 전문 번역가입니다. 사용자의 지시에 따라 원문을 도착 언어로 번역하고, \
고유명사를 추출하며, 내용 요약을 작성하고, 문체를 분석합니다.\
"""
def build_phase1_user_prompt(source_text: str, target_language: str) -> str:
return f"""\
다음 원문을 "{target_language}"으로 번역하세요.
번역 결과를 반드시 아래 JSON 포맷으로만 반환하세요 (마크다운 코드 블록 없이 순수 JSON):
{{
"translated": "(도착어 번역문)",
"proper_nouns": [
{{"original": "(원문 고유명사)", "suggested": "(제안 도착어 번역)"}}
],
"summary": "(내용 요약, 2-3문장以内)",
"style": "(문체 분석: 예: '공식적인', '친근한', '학술적', '보도풍', '문학적이고 서정적인' 등)"
}}
고유명사 추출 규칙:
- 인물명,地名, 기관명, 제품명, 작품명 고유 명사를 포함
- 일반 명사는 제외 (: "정부", "회사" 제외)
- 없는 경우 배열 [] 반환
원문:
---
{source_text}
---"""
# ── Phase 3: 재번역 (고유명사 + 문체 적용) ───────────────
SYSTEM_PROMPT_PHASE3 = """\
당신은 숙련된 번역 교정가입니다. 제공된 초벌 번역문을 고유명사 매핑과 \
문체 지시를 엄격히 적용하여 재번역하세요.\
"""
def build_phase3_user_prompt(
phase1_translated: str,
proper_nouns: list[dict],
style: str,
target_language: str,
) -> str:
pn_lines = "\n".join(
f" - {pn['original']}{pn.get('final', pn.get('suggested', ''))}"
for pn in proper_nouns if pn.get("original") or pn.get("suggested")
)
return f"""\
다음 초벌 번역문을 "{target_language}"으로 재번역하세요.
고유명사 매핑 (반드시 적용):
{pn_lines}
문체: {style}
재번역 규칙:
1. 고유명사 매핑을 반드시 준수하세요
2. 지시된 문체에 맞게 어조를 조정하세요
3. 초벌 번역의 의미는 유지하되 자연스럽고 정확히 다듬으세요
4. 결과만 반환하세요 (설명 없이 번역문만)
초벌 번역:
---
{phase1_translated}
---"""
# ── Phase 4: 가독성 개선 ───────────────────────────────
SYSTEM_PROMPT_PHASE4 = """\
당신은 "{target_language}" 원어민 편집자입니다. 제공된 번역문을 \
"{target_language}" 화자에게 읽기 편하도록 다듬으세요.\
"""
def build_phase4_user_prompt(
phase3_result: str,
proper_nouns: list[dict],
style: str,
) -> str:
pn_lines = "\n".join(
f" - {pn['original']}{pn.get('final', pn.get('suggested', ''))}"
for pn in proper_nouns if pn.get("original") or pn.get("suggested")
)
return f"""\
다음 번역문을 읽기 편하고 자연스러운 문장으로 다듬으세요.
고유명사 (변경 금지):
{pn_lines}
문체: {style}
다듬기 규칙:
1. 고유명사는 절대 변경하지 마세요
2. 한국어 화자에게 자연스럽게 읽히도록 어순과 표현을 조정하세요
3. 문장은 간결하고 명확하게 다듬으세요
4. 의미는 반드시 유지하세요
5. 결과만 반환하세요 (설명 없이 번역문만)
번역문:
---
{phase3_result}
---"""

70
backend/sessions.py Normal file
View file

@ -0,0 +1,70 @@
"""In-memory translation session store with 24h TTL."""
import uuid
from datetime import datetime, timezone, timedelta
from typing import Any
from backend.models import TranslationSession
class SessionStore:
"""Thread-safe-ish in-memory dict for translation sessions.
Each session is keyed by a UUID and has a 24-hour TTL.
Expired sessions are lazily cleaned on access.
"""
def __init__(self, ttl_hours: int = 24):
self._store: dict[str, dict[str, Any]] = {}
self.ttl_hours = ttl_hours
def create(self, session_data: TranslationSession) -> str:
session_id = uuid.uuid4().hex[:16]
self._store[session_id] = {
"data": session_data.model_dump(),
"created_at": datetime.now(timezone.utc),
"updated_at": datetime.now(timezone.utc),
}
return session_id
def get(self, session_id: str) -> TranslationSession | None:
entry = self._store.get(session_id)
if entry is None:
return None
# Check TTL
now = datetime.now(timezone.utc)
age = now - entry["updated_at"]
if age > timedelta(hours=self.ttl_hours):
del self._store[session_id]
return None
# Touch (refresh TTL)
entry["updated_at"] = now
return TranslationSession(**entry["data"])
def update(self, session_id: str, data: dict[str, Any]) -> TranslationSession | None:
entry = self._store.get(session_id)
if entry is None:
return None
entry["data"].update(data)
entry["updated_at"] = datetime.now(timezone.utc)
return TranslationSession(**entry["data"])
def delete(self, session_id: str) -> bool:
if session_id in self._store:
del self._store[session_id]
return True
return False
def cleanup_expired(self):
"""Remove all expired sessions."""
now = datetime.now(timezone.utc)
expired_keys = [
k for k, v in self._store.items()
if (now - v["updated_at"]) > timedelta(hours=self.ttl_hours)
]
for k in expired_keys:
del self._store[k]
# Singleton instance
session_store = SessionStore(ttl_hours=24)