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

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)"}