Improve translation reliability and progress feedback
This commit is contained in:
parent
9439858b11
commit
51a6e845d1
20 changed files with 1194 additions and 216 deletions
543
backend/main.py
543
backend/main.py
|
|
@ -1,12 +1,17 @@
|
|||
"""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 pydantic import BaseModel
|
||||
from openai import APITimeoutError, OpenAIError
|
||||
from pydantic import ValidationError
|
||||
|
||||
from backend import auth as auth_mod
|
||||
from backend.auth import get_current_user
|
||||
|
|
@ -24,22 +29,27 @@ from backend.models import (
|
|||
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 session_store
|
||||
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(
|
||||
|
|
@ -57,9 +67,13 @@ app.add_middleware(
|
|||
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")
|
||||
):
|
||||
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"]})
|
||||
|
|
@ -74,7 +88,7 @@ async def me(user_id: str = Depends(get_current_user)):
|
|||
# ── LLM Config Routes ────────────────────────────────
|
||||
|
||||
@app.get("/api/models")
|
||||
async def list_models():
|
||||
async def list_models(_user_id: str = Depends(get_current_user)):
|
||||
"""Return available LLM model configurations."""
|
||||
configs = get_llm_configs()
|
||||
return [
|
||||
|
|
@ -90,7 +104,10 @@ async def list_models():
|
|||
# ── Session Routes ───────────────────────────────────
|
||||
|
||||
@app.post("/api/translate/create")
|
||||
async def create_session(req: CreateSessionRequest):
|
||||
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,
|
||||
|
|
@ -100,30 +117,71 @@ async def create_session(req: CreateSessionRequest):
|
|||
model_phase3=req.model_phase3,
|
||||
model_phase4=req.model_phase4,
|
||||
)
|
||||
session_id = session_store.create(session_data)
|
||||
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):
|
||||
data = session_store.get(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):
|
||||
deleted = session_store.delete(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):
|
||||
async def update_session(
|
||||
session_id: str,
|
||||
req: PhaseUpdateRequest,
|
||||
user_id: str = Depends(get_current_user),
|
||||
):
|
||||
data = req.model_dump(exclude_none=True)
|
||||
updated = session_store.update(session_id, data)
|
||||
_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()
|
||||
|
|
@ -132,46 +190,69 @@ async def update_session(session_id: str, req: PhaseUpdateRequest):
|
|||
# ── Phase Execution Routes ───────────────────────────
|
||||
|
||||
@app.post("/api/translate/{session_id}/phase1")
|
||||
async def run_phase1(session_id: str):
|
||||
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."""
|
||||
session = session_store.get(session_id)
|
||||
if session is None:
|
||||
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="원문을 입력하세요")
|
||||
|
||||
# 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% 이내로 줄여주세요)",
|
||||
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))
|
||||
|
||||
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", ""),
|
||||
)
|
||||
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 = session_store.update(session_id, update_data)
|
||||
updated = _update_phase_result(session_id, update_data, user_id, version)
|
||||
if updated is None:
|
||||
raise HTTPException(status_code=404, detail="세션을 찾을 수 없습니다")
|
||||
|
||||
|
|
@ -179,17 +260,25 @@ async def run_phase1(session_id: str):
|
|||
|
||||
|
||||
@app.post("/api/translate/{session_id}/phase2")
|
||||
async def run_phase2(session_id: str, req: Phase2ConfirmRequest):
|
||||
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)
|
||||
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)
|
||||
updated = session_store.update(session_id, update_data, user_id)
|
||||
if updated is None:
|
||||
raise HTTPException(status_code=404, detail="세션을 찾을 수 없습니다")
|
||||
|
||||
|
|
@ -197,27 +286,55 @@ async def run_phase2(session_id: str, req: Phase2ConfirmRequest):
|
|||
|
||||
|
||||
@app.post("/api/translate/{session_id}/phase3")
|
||||
async def run_phase3(session_id: str):
|
||||
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."""
|
||||
session = session_store.get(session_id)
|
||||
if session is None:
|
||||
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
|
||||
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,
|
||||
)
|
||||
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)
|
||||
|
||||
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)
|
||||
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="세션을 찾을 수 없습니다")
|
||||
|
||||
|
|
@ -225,29 +342,50 @@ async def run_phase3(session_id: str):
|
|||
|
||||
|
||||
@app.post("/api/translate/{session_id}/phase4")
|
||||
async def run_phase4(session_id: str):
|
||||
async def run_phase4(session_id: str, user_id: str = Depends(get_current_user)):
|
||||
"""Run Phase 4: polish for readability."""
|
||||
session = session_store.get(session_id)
|
||||
if session is None:
|
||||
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
|
||||
|
||||
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
|
||||
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)
|
||||
result_text = await chat_complete(alias, sys_prompt, user_prompt)
|
||||
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": result_text}
|
||||
updated = session_store.update(session_id, update_data)
|
||||
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="세션을 찾을 수 없습니다")
|
||||
|
||||
|
|
@ -256,19 +394,276 @@ async def run_phase4(session_id: str):
|
|||
|
||||
# ── 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 JSON from LLM response, handling markdown code blocks."""
|
||||
"""Extract a likely JSON object from code fences or surrounding prose."""
|
||||
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"):
|
||||
if part.lower().startswith("json"):
|
||||
part = part[4:].strip()
|
||||
if part.startswith("{"):
|
||||
return part
|
||||
return text
|
||||
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) ───────────────────
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue