Initial working translator implementation
This commit is contained in:
commit
9439858b11
35 changed files with 5365 additions and 0 deletions
286
backend/main.py
Normal file
286
backend/main.py
Normal 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)"}
|
||||
Loading…
Add table
Add a link
Reference in a new issue