681 lines
24 KiB
Python
681 lines
24 KiB
Python
"""FastAPI application — main entry point for the LLM Translator backend."""
|
||
|
||
import json
|
||
import os
|
||
import re
|
||
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
|
||
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,
|
||
TranslationChunk,
|
||
UserInfo,
|
||
TranslationSession,
|
||
)
|
||
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 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"].encode("utf-8")
|
||
)
|
||
except (KeyError, ValueError):
|
||
authenticated = False
|
||
if not authenticated:
|
||
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(_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.create(session_data, user_id)
|
||
return {"session_id": session_id}
|
||
|
||
|
||
@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 SessionResponse(session_id=session_id, data=data).model_dump()
|
||
|
||
|
||
@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.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)
|
||
_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": "",
|
||
"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})
|
||
|
||
updated = session_store.update(session_id, data, user_id)
|
||
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, 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
|
||
|
||
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,
|
||
"phase3_result": "",
|
||
"phase3_chunks": [],
|
||
"phase4_result": "",
|
||
"progress": None,
|
||
}
|
||
updated = _update_phase_result(session_id, update_data, user_id, version)
|
||
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,
|
||
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="세션을 찾을 수 없습니다")
|
||
|
||
update_data = {
|
||
"phase2_proper_nouns": [pn.model_dump() for pn in req.proper_nouns],
|
||
"phase2_style": req.style,
|
||
"phase3_result": "",
|
||
"phase3_chunks": [],
|
||
"phase4_result": "",
|
||
"progress": None,
|
||
}
|
||
updated = session_store.update(session_id, update_data, user_id)
|
||
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, 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
|
||
|
||
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)
|
||
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, 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
|
||
|
||
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)
|
||
if updated is None:
|
||
raise HTTPException(status_code=404, detail="세션을 찾을 수 없습니다")
|
||
|
||
return SessionResponse(session_id=session_id, data=updated).model_dump()
|
||
|
||
|
||
# ── 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
|
||
) -> TranslationSession | None:
|
||
try:
|
||
return session_store.update(
|
||
session_id, data, user_id, expected_version=expected_version
|
||
)
|
||
except SessionConflictError as exc:
|
||
raise HTTPException(
|
||
status_code=409,
|
||
detail="처리 중 세션이 변경되었습니다. 현재 상태에서 단계를 다시 실행하세요",
|
||
) from exc
|
||
|
||
|
||
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
|
||
|
||
|
||
# ── 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)"}
|