286 lines
10 KiB
Python
286 lines
10 KiB
Python
"""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)"}
|