LLM-translator/backend/main.py

904 lines
32 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.

"""FastAPI application — main entry point for the LLM Translator backend."""
import json
import os
import re
import sqlite3
from collections import Counter
from pathlib import Path
from time import monotonic
from fastapi import Depends, FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from openai import APITimeoutError, OpenAIError
from pydantic import ValidationError
from backend import auth as auth_mod
from backend.auth import get_current_user, hash_password, require_admin
from backend.database import LastAdminError, database
from backend.llm_client import (
chat_complete,
get_llm_configs,
get_context_size,
)
from backend.models import (
CreateSessionRequest,
CreateUserRequest,
LoginRequest,
Phase1Result,
Phase2ConfirmRequest,
PhaseUpdateRequest,
ProperNoun,
SessionResponse,
SessionSummary,
TokenResponse,
TranslationChunk,
UserInfo,
TranslationSession,
UpdateUserRequest,
UserSummary,
)
from backend.prompts import (
SYSTEM_PROMPT_PHASE1,
SYSTEM_PROMPT_PHASE3,
SYSTEM_PROMPT_PHASE4,
build_phase1_user_prompt,
build_phase3_user_prompt,
build_phase4_user_prompt,
)
from backend.sessions import SessionArchivedError, SessionConflictError, session_store
# ── App Setup ────────────────────────────────────────
app = FastAPI(title="LLM Translator", version="0.1.0")
TRANSLATION_CHUNK_CHARS = max(
500, int(os.getenv("TRANSLATION_CHUNK_CHARS", "1500"))
)
# 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)
try:
authenticated = user is not None and _bcrypt.checkpw(
req.password.encode("utf-8"), user["password_hash"].encode("utf-8")
)
except (KeyError, ValueError):
authenticated = False
if not authenticated:
raise HTTPException(status_code=401, detail="ID 또는 비밀번호가 틀렸습니다")
if not user["is_active"]:
raise HTTPException(status_code=401, detail="비활성화된 계정입니다")
token = auth_mod.create_access_token(
{"user_id": user["id"], "token_version": user["token_version"]}
)
return TokenResponse(access_token=token)
@app.get("/api/auth/me")
async def me(user_id: str = Depends(get_current_user)):
user = database.get_user(user_id)
return UserInfo(user_id=user_id, is_admin=bool(user["is_admin"]))
@app.get("/api/admin/users")
async def list_users(_admin_id: str = Depends(require_admin)):
return [UserSummary(**user).model_dump() for user in database.list_users()]
@app.post("/api/admin/users", status_code=201)
async def create_user(req: CreateUserRequest, _admin_id: str = Depends(require_admin)):
try:
database.create_user(req.id, hash_password(req.password), is_admin=req.is_admin)
except sqlite3.IntegrityError as exc:
raise HTTPException(status_code=409, detail="이미 존재하는 사용자입니다") from exc
return {"created": True, "id": req.id}
@app.patch("/api/admin/users/{target_user_id}")
async def update_user(
target_user_id: str,
req: UpdateUserRequest,
admin_id: str = Depends(require_admin),
):
if target_user_id == admin_id and (req.is_active is False or req.is_admin is False):
raise HTTPException(status_code=400, detail="자기 관리자 권한을 제거할 수 없습니다")
try:
updated = database.update_user(
target_user_id,
password_hash=hash_password(req.password) if req.password else None,
is_admin=req.is_admin,
is_active=req.is_active,
)
except LastAdminError as exc:
raise HTTPException(
status_code=409, detail="최소 한 명의 활성 관리자가 필요합니다"
) from exc
if not updated:
raise HTTPException(status_code=404, detail="사용자를 찾을 수 없습니다")
return {"updated": True}
# ── LLM Config Routes ────────────────────────────────
@app.get("/api/models")
async def list_models(_user_id: str = Depends(get_current_user)):
"""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, user_id: str = Depends(get_current_user)
):
_validate_model_aliases(req.model_dump())
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.db.create_session(session_data, user_id, req.title)
return {"session_id": session_id}
@app.get("/api/sessions")
async def list_sessions(
include_archived: bool = False,
limit: int = 50,
user_id: str = Depends(get_current_user),
):
return [
SessionSummary(**item).model_dump()
for item in session_store.list(user_id, include_archived, limit)
]
@app.get("/api/sessions/{session_id}")
async def get_session(session_id: str, user_id: str = Depends(get_current_user)):
data = session_store.get(session_id, user_id)
if data is None:
raise HTTPException(status_code=404, detail="세션을 찾을 수 없습니다")
return _session_response(session_id, user_id, data)
@app.get("/api/sessions/{session_id}/progress")
async def get_session_progress(
session_id: str, user_id: str = Depends(get_current_user)
):
data = session_store.get(session_id, user_id)
if data is None:
raise HTTPException(status_code=404, detail="세션을 찾을 수 없습니다")
return {"progress": data.progress.model_dump() if data.progress else None}
@app.delete("/api/sessions/{session_id}")
async def delete_session(session_id: str, user_id: str = Depends(get_current_user)):
deleted = session_store.delete(session_id, user_id)
if not deleted:
raise HTTPException(status_code=404, detail="세션을 찾을 수 없습니다")
return {"deleted": True}
@app.post("/api/sessions/{session_id}/archive")
async def archive_session(session_id: str, user_id: str = Depends(get_current_user)):
if not session_store.archive(session_id, user_id):
raise HTTPException(status_code=404, detail="세션을 찾을 수 없습니다")
return {"archived": True}
@app.post("/api/sessions/{session_id}/restore")
async def restore_session(session_id: str, user_id: str = Depends(get_current_user)):
if not session_store.archive(session_id, user_id, archived=False):
raise HTTPException(status_code=404, detail="세션을 찾을 수 없습니다")
return {"restored": True}
@app.post("/api/sessions/{session_id}/clone", status_code=201)
async def clone_session(session_id: str, user_id: str = Depends(get_current_user)):
cloned_id = session_store.clone(session_id, user_id)
if not cloned_id:
raise HTTPException(status_code=404, detail="세션을 찾을 수 없습니다")
return {"session_id": cloned_id}
@app.get("/api/sessions/{session_id}/revision")
async def get_revision(session_id: str, user_id: str = Depends(get_current_user)):
revision = session_store.get_revision(session_id, user_id)
if not revision:
raise HTTPException(status_code=404, detail="저장된 직전 버전이 없습니다")
return revision
@app.post("/api/sessions/{session_id}/revision/restore")
async def restore_revision(session_id: str, user_id: str = Depends(get_current_user)):
try:
restored = session_store.restore_revision(session_id, user_id)
except SessionArchivedError as exc:
raise HTTPException(status_code=409, detail="보관된 세션은 수정할 수 없습니다") from exc
if not restored:
raise HTTPException(status_code=404, detail="저장된 직전 버전이 없습니다")
data = session_store.get(session_id, user_id)
return _session_response(session_id, user_id, data)
@app.patch("/api/sessions/{session_id}")
async def update_session(
session_id: str,
req: PhaseUpdateRequest,
user_id: str = Depends(get_current_user),
):
data = req.model_dump(exclude_none=True)
title = data.pop("title", None)
expected_version = data.pop("expected_version", None)
_validate_model_aliases(data)
current = session_store.get(session_id, user_id)
if current is None:
raise HTTPException(status_code=404, detail="세션을 찾을 수 없습니다")
changed = {key for key, value in data.items() if getattr(current, key) != value}
if changed & {"source_text", "source_language", "target_language", "model_phase1"}:
data.update({
"phase1_result": None,
"phase1_chunks": [],
"phase2_proper_nouns": [],
"phase2_style": "",
"phase2_confirmed": False,
"phase3_result": "",
"phase3_chunks": [],
"phase4_result": "",
"progress": None,
})
elif changed & {"model_phase3"}:
data.update({
"phase3_result": "",
"phase3_chunks": [],
"phase4_result": "",
"progress": None,
})
elif changed & {"model_phase4"}:
data.update({"phase4_result": "", "progress": None})
snapshot_reason = "session_edit" if changed else None
current_phase = None
if changed & {"source_text", "source_language", "target_language", "model_phase1"}:
current_phase = 0
elif changed & {"model_phase3"}:
current_phase = 2 if current.phase2_confirmed else (1 if current.phase1_result else 0)
elif changed & {"model_phase4"}:
current_phase = 3 if current.phase3_result else (1 if current.phase1_result else 0)
try:
updated = session_store.update(
session_id,
data,
user_id,
expected_version=expected_version,
snapshot_reason=snapshot_reason,
current_phase=current_phase,
)
except SessionConflictError as exc:
raise HTTPException(
status_code=409, detail="다른 변경이 먼저 저장되었습니다. 세션을 다시 불러오세요"
) from exc
except SessionArchivedError as exc:
raise HTTPException(status_code=409, detail="보관된 세션은 수정할 수 없습니다") from exc
if updated is None:
raise HTTPException(status_code=404, detail="세션을 찾을 수 없습니다")
if title is not None:
session_store.rename(session_id, user_id, title)
return _session_response(session_id, user_id, updated)
# ── Phase Execution Routes ───────────────────────────
@app.post("/api/translate/{session_id}/phase1")
async def run_phase1(session_id: str, user_id: str = Depends(get_current_user)):
"""Run Phase 1: rough translation + proper noun extraction + summary + style analysis."""
snapshot = session_store.get_with_version(session_id, user_id)
if snapshot is None:
raise HTTPException(status_code=404, detail="세션을 찾을 수 없습니다")
session, version = snapshot
_ensure_not_archived(session_id, user_id)
alias = session.model_phase1
if not alias:
raise HTTPException(status_code=400, detail="Phase 1 LLM 모델을 선택하세요")
if not session.source_text.strip():
raise HTTPException(status_code=400, detail="원문을 입력하세요")
chunk_results: list[tuple[str, Phase1Result]] = []
source_chunks = _split_text(session.source_text)
for chunk_index, source_chunk in enumerate(source_chunks, start=1):
user_prompt = build_phase1_user_prompt(
source_chunk, session.source_language, session.target_language
)
_ensure_context_capacity(alias, f"{SYSTEM_PROMPT_PHASE1}\n{user_prompt}")
raw_response = await _chat_complete(
alias,
SYSTEM_PROMPT_PHASE1,
user_prompt,
session_id=session_id,
user_id=user_id,
phase=1,
chunk_index=chunk_index,
total_chunks=len(source_chunks),
status="초벌 번역 수신 중",
)
chunk_result = await _recover_phase1_response(
raw_response,
source_chunk,
alias,
session_id,
user_id,
chunk_index,
len(source_chunks),
session.target_language,
)
chunk_results.append((source_chunk, chunk_result))
result = _merge_phase1_results([item[1] for item in chunk_results])
# Update session with Phase 1 results and initialize Phase 2 data
update_data = {
"phase1_result": result.model_dump(),
"phase1_chunks": [
TranslationChunk(
source_text=source_chunk,
translated=chunk_result.translated,
).model_dump()
for source_chunk, chunk_result in chunk_results
],
"phase2_proper_nouns": [pn.model_dump() for pn in result.proper_nouns],
"phase2_style": result.style,
"phase2_confirmed": False,
"phase3_result": "",
"phase3_chunks": [],
"phase4_result": "",
"progress": None,
}
updated = _update_phase_result(
session_id,
update_data,
user_id,
version,
snapshot_reason="phase1_rerun",
current_phase=1,
)
if updated is None:
raise HTTPException(status_code=404, detail="세션을 찾을 수 없습니다")
return _session_response(session_id, user_id, updated)
@app.post("/api/translate/{session_id}/phase2")
async def run_phase2(
session_id: str,
req: Phase2ConfirmRequest,
user_id: str = Depends(get_current_user),
):
"""Phase 2: user confirms or edits proper nouns and style."""
session = session_store.get(session_id, user_id)
if session is None:
raise HTTPException(status_code=404, detail="세션을 찾을 수 없습니다")
if session.phase1_result is None:
raise HTTPException(status_code=400, detail="먼저 Phase 1을 실행하세요")
_ensure_not_archived(session_id, user_id)
update_data = {
"phase2_proper_nouns": [pn.model_dump() for pn in req.proper_nouns],
"phase2_style": req.style,
"phase2_confirmed": True,
"phase3_result": "",
"phase3_chunks": [],
"phase4_result": "",
"progress": None,
}
try:
updated = session_store.update(
session_id,
update_data,
user_id,
expected_version=req.expected_version,
snapshot_reason="phase2_edit",
current_phase=2,
)
except SessionConflictError as exc:
raise HTTPException(
status_code=409, detail="다른 변경이 먼저 저장되었습니다. 세션을 다시 불러오세요"
) from exc
except SessionArchivedError as exc:
raise HTTPException(status_code=409, detail="보관된 세션은 수정할 수 없습니다") from exc
if updated is None:
raise HTTPException(status_code=404, detail="세션을 찾을 수 없습니다")
return _session_response(session_id, user_id, updated)
@app.post("/api/translate/{session_id}/phase3")
async def run_phase3(session_id: str, user_id: str = Depends(get_current_user)):
"""Run Phase 3: re-translation with proper noun and style constraints."""
snapshot = session_store.get_with_version(session_id, user_id)
if snapshot is None:
raise HTTPException(status_code=404, detail="세션을 찾을 수 없습니다")
session, version = snapshot
_ensure_not_archived(session_id, user_id)
if not session.phase1_result:
raise HTTPException(status_code=400, detail="먼저 Phase 1을 실행하세요")
alias = session.model_phase3 or session.model_phase1
phase1_chunks = session.phase1_chunks or [
TranslationChunk(
source_text=session.source_text,
translated=session.phase1_result.translated,
)
]
result_chunks: list[str] = []
for chunk_index, chunk in enumerate(phase1_chunks, start=1):
user_prompt = build_phase3_user_prompt(
source_text=chunk.source_text,
phase1_translated=chunk.translated,
proper_nouns=[pn.model_dump() for pn in session.phase2_proper_nouns],
style=session.phase2_style or session.phase1_result.style,
target_language=session.target_language,
)
_ensure_context_capacity(alias, f"{SYSTEM_PROMPT_PHASE3}\n{user_prompt}")
chunk_result = await _chat_complete(
alias,
SYSTEM_PROMPT_PHASE3,
user_prompt,
session_id=session_id,
user_id=user_id,
phase=3,
chunk_index=chunk_index,
total_chunks=len(phase1_chunks),
status="재번역 수신 중",
)
if not chunk_result:
raise HTTPException(status_code=502, detail="LLM이 재번역 결과를 반환하지 않았습니다")
result_chunks.append(chunk_result)
update_data = {
"phase3_result": "\n\n".join(result_chunks),
"phase3_chunks": result_chunks,
"phase4_result": "",
"progress": None,
}
updated = _update_phase_result(
session_id,
update_data,
user_id,
version,
snapshot_reason="phase3_rerun",
current_phase=3,
)
if updated is None:
raise HTTPException(status_code=404, detail="세션을 찾을 수 없습니다")
return _session_response(session_id, user_id, updated)
@app.post("/api/translate/{session_id}/phase4")
async def run_phase4(session_id: str, user_id: str = Depends(get_current_user)):
"""Run Phase 4: polish for readability."""
snapshot = session_store.get_with_version(session_id, user_id)
if snapshot is None:
raise HTTPException(status_code=404, detail="세션을 찾을 수 없습니다")
session, version = snapshot
_ensure_not_archived(session_id, user_id)
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
source_chunks = session.phase3_chunks or _split_text(session.phase3_result)
result_chunks: list[str] = []
sys_prompt = SYSTEM_PROMPT_PHASE4.replace("{target_language}", session.target_language)
for chunk_index, source_chunk in enumerate(source_chunks, start=1):
user_prompt = build_phase4_user_prompt(
phase3_result=source_chunk,
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,
)
_ensure_context_capacity(alias, f"{sys_prompt}\n{user_prompt}")
chunk_result = await _chat_complete(
alias,
sys_prompt,
user_prompt,
session_id=session_id,
user_id=user_id,
phase=4,
chunk_index=chunk_index,
total_chunks=len(source_chunks),
status="마무리 번역 수신 중",
)
if not chunk_result:
raise HTTPException(status_code=502, detail="LLM이 마무리 결과를 반환하지 않았습니다")
result_chunks.append(chunk_result)
update_data = {
"phase4_result": "\n\n".join(result_chunks),
"progress": None,
}
updated = _update_phase_result(
session_id,
update_data,
user_id,
version,
snapshot_reason="phase4_rerun",
current_phase=4,
)
if updated is None:
raise HTTPException(status_code=404, detail="세션을 찾을 수 없습니다")
return _session_response(session_id, user_id, updated)
# ── Helpers ───────────────────────────────────────────
def _split_text(text: str) -> list[str]:
"""Split long text near sentence or line boundaries for reliable LLM calls."""
remaining = text.strip()
chunks: list[str] = []
boundary_chars = "\n。!?!?;."
while len(remaining) > TRANSLATION_CHUNK_CHARS:
window = remaining[:TRANSLATION_CHUNK_CHARS]
minimum_boundary = TRANSLATION_CHUNK_CHARS // 2
split_at = max(window.rfind(char) for char in boundary_chars)
if split_at < minimum_boundary:
whitespace_at = max(window.rfind(" "), window.rfind("\t"))
split_at = (
whitespace_at + 1
if whitespace_at >= minimum_boundary
else TRANSLATION_CHUNK_CHARS
)
else:
split_at += 1
chunks.append(remaining[:split_at].strip())
remaining = remaining[split_at:].lstrip()
if remaining:
chunks.append(remaining)
return chunks
def _parse_phase1_response(raw_response: str) -> Phase1Result | None:
extracted = _extract_json(raw_response)
candidates = [extracted, re.sub(r",\s*([}\]])", r"\1", extracted)]
for candidate in candidates:
try:
parsed = json.loads(candidate)
if not isinstance(parsed, dict):
continue
translated = parsed.get("translated") or parsed.get("translation") or ""
proper_nouns = []
for item in parsed.get("proper_nouns") or []:
if not isinstance(item, dict) or not item.get("original"):
continue
proper_nouns.append(
ProperNoun(
original=str(item["original"]),
suggested=str(
item.get("suggested") or item.get("final") or item["original"]
),
final=str(item.get("final") or ""),
)
)
result = Phase1Result(
translated=str(translated),
proper_nouns=proper_nouns,
summary=str(parsed.get("summary") or ""),
style=str(parsed.get("style") or ""),
)
if result.translated.strip():
return result
except (json.JSONDecodeError, TypeError, ValueError, ValidationError):
continue
return None
async def _recover_phase1_response(
raw_response: str,
source_text: str,
alias: str,
session_id: str,
user_id: str,
chunk_index: int,
total_chunks: int,
target_language: str,
) -> Phase1Result:
parsed = _parse_phase1_response(raw_response)
if parsed is not None:
return parsed
repair_system = """\
당신은 손상된 JSON을 복구하는 도구입니다. 내용은 번역하거나 요약하지 말고, 주어진 응답의 내용을 보존하여 순수 JSON 객체만 반환하세요.\
"""
repair_prompt = f"""\
다음 응답을 translated, proper_nouns, summary, style 필드를 가진 유효한 JSON으로 복구하세요.
proper_nouns의 각 항목은 original과 suggested 필드를 가져야 합니다.
손상된 응답:
---
{raw_response}
---"""
repaired_response = await _chat_complete(
alias,
repair_system,
repair_prompt,
temperature=0.0,
session_id=session_id,
user_id=user_id,
phase=1,
chunk_index=chunk_index,
total_chunks=total_chunks,
status="JSON 응답 복구 중",
)
parsed = _parse_phase1_response(repaired_response)
if parsed is not None:
parsed.warnings.append("LLM의 JSON 응답을 자동으로 복구했습니다.")
return parsed
fallback_prompt = f"""\
다음 원문을 "{target_language}"으로 정확하게 번역하세요.
설명이나 JSON 없이 번역문만 반환하세요.
원문:
---
{source_text}
---"""
translated = await _chat_complete(
alias,
SYSTEM_PROMPT_PHASE1,
fallback_prompt,
session_id=session_id,
user_id=user_id,
phase=1,
chunk_index=chunk_index,
total_chunks=total_chunks,
status="일반 번역으로 전환 중",
)
if not translated:
raise HTTPException(status_code=502, detail="LLM이 번역 결과를 반환하지 않았습니다")
return Phase1Result(
translated=translated,
proper_nouns=[],
summary="",
style="",
warnings=[
"JSON 응답을 복구하지 못해 일반 번역으로 대체했습니다. "
"이 조각의 고유명사, 요약, 문체 정보는 비어 있습니다."
],
)
def _merge_phase1_results(results: list[Phase1Result]) -> Phase1Result:
proper_nouns: list[ProperNoun] = []
seen_originals: set[str] = set()
for result in results:
for proper_noun in result.proper_nouns:
key = proper_noun.original.strip()
if key and key not in seen_originals:
proper_nouns.append(proper_noun)
seen_originals.add(key)
styles = [result.style for result in results if result.style]
style = Counter(styles).most_common(1)[0][0] if styles else ""
return Phase1Result(
translated="\n\n".join(result.translated for result in results),
proper_nouns=proper_nouns,
summary="\n".join(result.summary for result in results if result.summary),
style=style,
warnings=[warning for result in results for warning in result.warnings],
)
def _validate_model_aliases(data: dict) -> None:
available = {config.alias for config in get_llm_configs()}
invalid = {
value
for key, value in data.items()
if key.startswith("model_phase") and value and value not in available
}
if invalid:
raise HTTPException(
status_code=400,
detail=f"알 수 없는 LLM 모델입니다: {', '.join(sorted(invalid))}",
)
async def _chat_complete(
alias: str,
system_prompt: str,
user_prompt: str,
temperature: float = 0.3,
*,
session_id: str | None = None,
user_id: str | None = None,
phase: int = 0,
chunk_index: int = 1,
total_chunks: int = 1,
status: str = "응답 수신 중",
) -> str:
parts: list[str] = []
last_progress_update = 0.0
def set_progress(progress_status: str, preview: str = "") -> None:
if session_id and user_id:
session_store.set_progress(
session_id,
{
"phase": phase,
"chunk": chunk_index,
"total_chunks": total_chunks,
"status": progress_status,
"preview": preview[-3000:],
},
user_id,
)
async def on_delta(delta: str) -> None:
nonlocal last_progress_update
parts.append(delta)
now = monotonic()
if now - last_progress_update >= 0.25:
set_progress(status, "".join(parts))
last_progress_update = now
set_progress("LLM 응답 대기 중")
try:
result = await chat_complete(
alias,
system_prompt,
user_prompt,
temperature=temperature,
on_delta=on_delta if session_id and user_id else None,
)
set_progress(f"{chunk_index}/{total_chunks} 조각 수신 완료", result)
return result
except APITimeoutError as exc:
set_progress("응답 시간 초과", "".join(parts))
raise HTTPException(status_code=504, detail="LLM 서버 응답 시간이 초과되었습니다") from exc
except ValueError as exc:
set_progress("요청 오류", "".join(parts))
raise HTTPException(status_code=400, detail=str(exc)) from exc
except OpenAIError as exc:
set_progress("LLM 서버 요청 실패", "".join(parts))
raise HTTPException(status_code=502, detail="LLM 서버 요청에 실패했습니다") from exc
def _update_phase_result(
session_id: str,
data: dict,
user_id: str,
expected_version: int,
*,
snapshot_reason: str,
current_phase: int,
) -> TranslationSession | None:
try:
return session_store.update(
session_id,
data,
user_id,
expected_version=expected_version,
snapshot_reason=snapshot_reason,
current_phase=current_phase,
)
except SessionConflictError as exc:
raise HTTPException(
status_code=409,
detail="처리 중 세션이 변경되었습니다. 현재 상태에서 단계를 다시 실행하세요",
) from exc
except SessionArchivedError as exc:
raise HTTPException(status_code=409, detail="보관된 세션은 수정할 수 없습니다") from exc
def _session_response(
session_id: str, user_id: str, data: TranslationSession
) -> dict:
metadata = session_store.metadata(session_id, user_id)
if metadata is None:
raise HTTPException(status_code=404, detail="세션을 찾을 수 없습니다")
return SessionResponse(
session_id=session_id,
data=data,
title=metadata["title"],
status=metadata["status"],
current_phase=metadata["current_phase"],
version=metadata["version"],
created_at=metadata["created_at"],
updated_at=metadata["updated_at"],
archived_at=metadata["archived_at"],
).model_dump()
def _ensure_not_archived(session_id: str, user_id: str) -> None:
metadata = session_store.metadata(session_id, user_id)
if metadata and metadata["archived_at"] is not None:
raise HTTPException(status_code=409, detail="보관된 세션은 수정할 수 없습니다")
def _ensure_context_capacity(alias: str, content: str) -> None:
context_size = get_context_size(alias)
if context_size and len(content) > context_size * 0.7:
raise HTTPException(
status_code=400,
detail=f"입력이 너무 깁니다 ({context_size} 컨텍스트의 70% 이내로 줄여주세요)",
)
def _extract_json(text: str) -> str:
"""Extract a likely JSON object from code fences or surrounding prose."""
text = text.strip()
if "```" in text:
parts = text.split("```")
for part in parts:
part = part.strip()
if part.lower().startswith("json"):
part = part[4:].strip()
if "{" in part and "}" in part:
text = part
break
start = text.find("{")
end = text.rfind("}")
return text[start : end + 1] if start >= 0 and end > start else text
@app.get("/api/health/live")
async def health_live():
return {"status": "ok"}
@app.get("/api/health/ready")
async def health_ready():
if not database.healthcheck() or not get_llm_configs():
raise HTTPException(status_code=503, detail="서비스가 준비되지 않았습니다")
return {"status": "ready"}
# ── 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)"}