Initial working translator implementation
This commit is contained in:
commit
9439858b11
35 changed files with 5365 additions and 0 deletions
1
backend/__init__.py
Normal file
1
backend/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
# LLM Translator Backend Package
|
||||
85
backend/auth.py
Normal file
85
backend/auth.py
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
"""JWT-based authentication with config-file user management."""
|
||||
|
||||
import os
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import bcrypt
|
||||
import jwt
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
|
||||
# ── Config ────────────────────────────────────────────
|
||||
|
||||
JWT_SECRET_KEY = os.getenv("JWT_SECRET_KEY", "dev-secret-key-change-in-production")
|
||||
JWT_ALGORITHM = "HS256"
|
||||
JWT_EXPIRE_HOURS = int(os.getenv("JWT_EXPIRE_HOURS", "24"))
|
||||
|
||||
security = HTTPBearer()
|
||||
|
||||
|
||||
# ── User Loading ───────────────────────────────────────
|
||||
|
||||
def _load_users_file():
|
||||
"""Load users from config file. Auto-hash plain-text passwords on first run."""
|
||||
import json as j
|
||||
config_path = os.getenv(
|
||||
"USERS_CONFIG_PATH", str(Path(__file__).parent.parent / "config" / "users.json")
|
||||
)
|
||||
with open(config_path, encoding="utf-8") as f:
|
||||
users_raw = j.load(f)
|
||||
|
||||
# Migrate plain-text passwords to bcrypt hashes (one-time on startup)
|
||||
migrated = False
|
||||
for user in users_raw:
|
||||
if "password_plain" in user and "password" not in user:
|
||||
pw_bytes = user.pop("password_plain").encode("utf-8")
|
||||
user["password"] = bcrypt.hashpw(pw_bytes, bcrypt.gensalt()).decode("utf-8")
|
||||
migrated = True
|
||||
|
||||
if migrated:
|
||||
with open(config_path, "w", encoding="utf-8") as f:
|
||||
j.dump(users_raw, f, ensure_ascii=False, indent=2)
|
||||
|
||||
return users_raw
|
||||
|
||||
|
||||
_users_db = _load_users_file()
|
||||
|
||||
|
||||
def get_user_by_username(username: str):
|
||||
for u in _users_db:
|
||||
if u["id"] == username:
|
||||
return u
|
||||
return None
|
||||
|
||||
|
||||
# ── Token Helpers ─────────────────────────────────────
|
||||
|
||||
def create_access_token(data: dict) -> str:
|
||||
to_encode = data.copy()
|
||||
expire = datetime.now(timezone.utc) + timedelta(hours=JWT_EXPIRE_HOURS)
|
||||
to_encode.update({"exp": expire})
|
||||
return jwt.encode(to_encode, JWT_SECRET_KEY, algorithm=JWT_ALGORITHM)
|
||||
|
||||
|
||||
def decode_access_token(token: str) -> dict | None:
|
||||
try:
|
||||
return jwt.decode(token, JWT_SECRET_KEY, algorithms=[JWT_ALGORITHM])
|
||||
except jwt.ExpiredSignatureError:
|
||||
return None
|
||||
except jwt.InvalidTokenError:
|
||||
return None
|
||||
|
||||
|
||||
# ── Dependency ───────────────────────────────────────
|
||||
|
||||
async def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)) -> str:
|
||||
"""Extract and validate JWT, returning the user_id."""
|
||||
payload = decode_access_token(credentials.credentials)
|
||||
if payload is None or "user_id" not in payload:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="유효하지 않거나 만료된 토큰입니다",
|
||||
)
|
||||
return payload["user_id"]
|
||||
88
backend/llm_client.py
Normal file
88
backend/llm_client.py
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
"""OpenAI-compatible LLM client for multi-server support."""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
from backend.models import LLMConfig
|
||||
|
||||
|
||||
# ── Server Config Loading ─────────────────────────────
|
||||
|
||||
def _load_llm_configs() -> list[LLMConfig]:
|
||||
config_path = os.getenv(
|
||||
"LLMS_CONFIG_PATH", str(Path(__file__).parent.parent / "config" / "llms.json")
|
||||
)
|
||||
import json
|
||||
with open(config_path, encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
return [LLMConfig(**item) for item in data]
|
||||
|
||||
|
||||
_llm_configs: list[LLMConfig] = []
|
||||
_clients: dict[str, AsyncOpenAI] = {} # alias -> client instance
|
||||
|
||||
|
||||
def get_llm_configs() -> list[LLMConfig]:
|
||||
"""Return all LLM configurations."""
|
||||
return _llm_configs
|
||||
|
||||
|
||||
def get_client_for_alias(alias: str) -> tuple[AsyncOpenAI, LLMConfig]:
|
||||
"""Get the AsyncOpenAI client and config for a given alias.
|
||||
|
||||
Creates a lazily cached client per alias.
|
||||
"""
|
||||
if alias not in _clients:
|
||||
cfg = None
|
||||
for c in _llm_configs:
|
||||
if c.alias == alias:
|
||||
cfg = c
|
||||
break
|
||||
if cfg is None:
|
||||
raise ValueError(f"Unknown LLM alias: {alias}")
|
||||
|
||||
client = AsyncOpenAI(
|
||||
api_key=cfg.api_key,
|
||||
base_url=cfg.base_url,
|
||||
timeout=120.0,
|
||||
)
|
||||
_clients[alias] = client
|
||||
|
||||
return _clients[alias], next(c for c in _llm_configs if c.alias == alias)
|
||||
|
||||
|
||||
async def chat_complete(
|
||||
alias: str,
|
||||
system_prompt: str,
|
||||
user_content: str,
|
||||
temperature: float = 0.3,
|
||||
) -> str:
|
||||
"""Send a single-turn chat completion and return the assistant message."""
|
||||
client, cfg = get_client_for_alias(alias)
|
||||
|
||||
response = await client.chat.completions.create(
|
||||
model=cfg.model,
|
||||
messages=[
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": user_content},
|
||||
],
|
||||
temperature=temperature,
|
||||
)
|
||||
|
||||
content = response.choices[0].message.content or ""
|
||||
return content.strip()
|
||||
|
||||
|
||||
def get_context_size(alias: str) -> int | None:
|
||||
"""Get the context window size for a given alias."""
|
||||
for c in _llm_configs:
|
||||
if c.alias == alias:
|
||||
return c.context_size
|
||||
return None
|
||||
|
||||
|
||||
# ── Initialize on import ──────────────────────────────
|
||||
|
||||
_llm_configs = _load_llm_configs()
|
||||
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)"}
|
||||
99
backend/models.py
Normal file
99
backend/models.py
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
"""Pydantic request/response models for the API."""
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
# ── Auth ───────────────────────────────────────────────
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
id: str
|
||||
password: str
|
||||
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
|
||||
|
||||
class UserInfo(BaseModel):
|
||||
user_id: str
|
||||
|
||||
|
||||
# ── LLM Config ───────────────────────────────────────
|
||||
|
||||
class LLMConfig(BaseModel):
|
||||
alias: str
|
||||
base_url: str
|
||||
api_key: str
|
||||
model: str
|
||||
context_size: int
|
||||
|
||||
|
||||
# ── Translation Session ────────────────────────────────
|
||||
|
||||
class ProperNoun(BaseModel):
|
||||
original: str # 원문 고유명사
|
||||
suggested: str # 제안 번역
|
||||
final: str = "" # 사용자 최종 결정 (Phase 2)
|
||||
|
||||
|
||||
class Phase1Result(BaseModel):
|
||||
translated: str # 초벌 번역 결과
|
||||
proper_nouns: list[ProperNoun] # 추출된 고유명사 리스트
|
||||
summary: str # 내용 요약
|
||||
style: str # 문체 분석 결과
|
||||
|
||||
|
||||
class TranslationSession(BaseModel):
|
||||
"""Full translation session state."""
|
||||
|
||||
source_text: str = "" # 원문
|
||||
source_language: str = "auto" # 출발어 (auto = 자동판단)
|
||||
target_language: str = "한국어" # 도착어
|
||||
model_phase1: str = "" # Phase 1 LLM 모델 alias
|
||||
model_phase2: str = "" # Phase 2 LLM 모델 alias
|
||||
model_phase3: str = "" # Phase 3 LLM 모델 alias
|
||||
model_phase4: str = "" # Phase 4 LLM 모델 alias
|
||||
phase1_result: Phase1Result | None = None # 초벌 번역 결과
|
||||
phase2_proper_nouns: list[ProperNoun] = [] # Phase 2에서 최종 확정된 고유명사
|
||||
phase2_style: str = "" # Phase 2에서 최종 확정된 문체
|
||||
phase3_result: str = "" # 재번역 결과
|
||||
phase4_result: str = "" # 마무리 다듬기 결과
|
||||
|
||||
|
||||
# ── API Requests ───────────────────────────────────────
|
||||
|
||||
class CreateSessionRequest(BaseModel):
|
||||
source_text: str
|
||||
source_language: str = "auto"
|
||||
target_language: str = "한국어"
|
||||
model_phase1: str = ""
|
||||
model_phase2: str = ""
|
||||
model_phase3: str = ""
|
||||
model_phase4: str = ""
|
||||
|
||||
|
||||
class PhaseUpdateRequest(BaseModel):
|
||||
"""Partial update for a specific phase."""
|
||||
|
||||
source_text: str | None = None
|
||||
source_language: str | None = None
|
||||
target_language: str | None = None
|
||||
model_phase1: str | None = None
|
||||
model_phase2: str | None = None
|
||||
model_phase3: str | None = None
|
||||
model_phase4: str | None = None
|
||||
|
||||
|
||||
class Phase2ConfirmRequest(BaseModel):
|
||||
"""User confirms or edits proper nouns and style for Phase 2."""
|
||||
|
||||
proper_nouns: list[ProperNoun]
|
||||
style: str
|
||||
|
||||
|
||||
# ── API Responses ──────────────────────────────────────
|
||||
|
||||
class SessionResponse(BaseModel):
|
||||
session_id: str
|
||||
data: TranslationSession
|
||||
113
backend/prompts.py
Normal file
113
backend/prompts.py
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
"""Phase 1~4 prompt templates for the translation pipeline."""
|
||||
|
||||
|
||||
# ── Phase 1: 초벌 번역 + 고유명사 추출 + 요약 + 문체 분석 ─
|
||||
|
||||
SYSTEM_PROMPT_PHASE1 = """\
|
||||
당신은 전문 번역가입니다. 사용자의 지시에 따라 원문을 도착 언어로 번역하고, \
|
||||
고유명사를 추출하며, 내용 요약을 작성하고, 문체를 분석합니다.\
|
||||
"""
|
||||
|
||||
|
||||
def build_phase1_user_prompt(source_text: str, target_language: str) -> str:
|
||||
return f"""\
|
||||
다음 원문을 "{target_language}"으로 번역하세요.
|
||||
|
||||
번역 결과를 반드시 아래 JSON 포맷으로만 반환하세요 (마크다운 코드 블록 없이 순수 JSON):
|
||||
|
||||
{{
|
||||
"translated": "(도착어 번역문)",
|
||||
"proper_nouns": [
|
||||
{{"original": "(원문 고유명사)", "suggested": "(제안 도착어 번역)"}}
|
||||
],
|
||||
"summary": "(내용 요약, 2-3문장以内)",
|
||||
"style": "(문체 분석: 예: '공식적인', '친근한', '학술적', '보도풍', '문학적이고 서정적인' 등)"
|
||||
}}
|
||||
|
||||
고유명사 추출 규칙:
|
||||
- 인물명,地名, 기관명, 제품명, 작품명 등 고유 명사를 포함
|
||||
- 일반 명사는 제외 (예: "정부", "회사"는 제외)
|
||||
- 없는 경우 빈 배열 []로 반환
|
||||
|
||||
원문:
|
||||
---
|
||||
{source_text}
|
||||
---"""
|
||||
|
||||
|
||||
# ── Phase 3: 재번역 (고유명사 + 문체 적용) ───────────────
|
||||
|
||||
SYSTEM_PROMPT_PHASE3 = """\
|
||||
당신은 숙련된 번역 교정가입니다. 제공된 초벌 번역문을 고유명사 매핑과 \
|
||||
문체 지시를 엄격히 적용하여 재번역하세요.\
|
||||
"""
|
||||
|
||||
|
||||
def build_phase3_user_prompt(
|
||||
phase1_translated: str,
|
||||
proper_nouns: list[dict],
|
||||
style: str,
|
||||
target_language: str,
|
||||
) -> str:
|
||||
pn_lines = "\n".join(
|
||||
f" - {pn['original']} → {pn.get('final', pn.get('suggested', ''))}"
|
||||
for pn in proper_nouns if pn.get("original") or pn.get("suggested")
|
||||
)
|
||||
|
||||
return f"""\
|
||||
다음 초벌 번역문을 "{target_language}"으로 재번역하세요.
|
||||
|
||||
고유명사 매핑 (반드시 적용):
|
||||
{pn_lines}
|
||||
|
||||
문체: {style}
|
||||
|
||||
재번역 규칙:
|
||||
1. 위 고유명사 매핑을 반드시 준수하세요
|
||||
2. 지시된 문체에 맞게 어조를 조정하세요
|
||||
3. 초벌 번역의 의미는 유지하되 더 자연스럽고 정확히 다듬으세요
|
||||
4. 결과만 반환하세요 (설명 없이 번역문만)
|
||||
|
||||
초벌 번역:
|
||||
---
|
||||
{phase1_translated}
|
||||
---"""
|
||||
|
||||
|
||||
# ── Phase 4: 가독성 개선 ───────────────────────────────
|
||||
|
||||
SYSTEM_PROMPT_PHASE4 = """\
|
||||
당신은 "{target_language}" 원어민 편집자입니다. 제공된 번역문을 \
|
||||
"{target_language}" 화자에게 읽기 편하도록 다듬으세요.\
|
||||
"""
|
||||
|
||||
|
||||
def build_phase4_user_prompt(
|
||||
phase3_result: str,
|
||||
proper_nouns: list[dict],
|
||||
style: str,
|
||||
) -> str:
|
||||
pn_lines = "\n".join(
|
||||
f" - {pn['original']} → {pn.get('final', pn.get('suggested', ''))}"
|
||||
for pn in proper_nouns if pn.get("original") or pn.get("suggested")
|
||||
)
|
||||
|
||||
return f"""\
|
||||
다음 번역문을 읽기 편하고 자연스러운 문장으로 다듬으세요.
|
||||
|
||||
고유명사 (변경 금지):
|
||||
{pn_lines}
|
||||
|
||||
문체: {style}
|
||||
|
||||
다듬기 규칙:
|
||||
1. 고유명사는 절대 변경하지 마세요
|
||||
2. 한국어 화자에게 자연스럽게 읽히도록 어순과 표현을 조정하세요
|
||||
3. 문장은 간결하고 명확하게 다듬으세요
|
||||
4. 의미는 반드시 유지하세요
|
||||
5. 결과만 반환하세요 (설명 없이 번역문만)
|
||||
|
||||
번역문:
|
||||
---
|
||||
{phase3_result}
|
||||
---"""
|
||||
70
backend/sessions.py
Normal file
70
backend/sessions.py
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
"""In-memory translation session store with 24h TTL."""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from typing import Any
|
||||
|
||||
from backend.models import TranslationSession
|
||||
|
||||
|
||||
class SessionStore:
|
||||
"""Thread-safe-ish in-memory dict for translation sessions.
|
||||
|
||||
Each session is keyed by a UUID and has a 24-hour TTL.
|
||||
Expired sessions are lazily cleaned on access.
|
||||
"""
|
||||
|
||||
def __init__(self, ttl_hours: int = 24):
|
||||
self._store: dict[str, dict[str, Any]] = {}
|
||||
self.ttl_hours = ttl_hours
|
||||
|
||||
def create(self, session_data: TranslationSession) -> str:
|
||||
session_id = uuid.uuid4().hex[:16]
|
||||
self._store[session_id] = {
|
||||
"data": session_data.model_dump(),
|
||||
"created_at": datetime.now(timezone.utc),
|
||||
"updated_at": datetime.now(timezone.utc),
|
||||
}
|
||||
return session_id
|
||||
|
||||
def get(self, session_id: str) -> TranslationSession | None:
|
||||
entry = self._store.get(session_id)
|
||||
if entry is None:
|
||||
return None
|
||||
# Check TTL
|
||||
now = datetime.now(timezone.utc)
|
||||
age = now - entry["updated_at"]
|
||||
if age > timedelta(hours=self.ttl_hours):
|
||||
del self._store[session_id]
|
||||
return None
|
||||
# Touch (refresh TTL)
|
||||
entry["updated_at"] = now
|
||||
return TranslationSession(**entry["data"])
|
||||
|
||||
def update(self, session_id: str, data: dict[str, Any]) -> TranslationSession | None:
|
||||
entry = self._store.get(session_id)
|
||||
if entry is None:
|
||||
return None
|
||||
entry["data"].update(data)
|
||||
entry["updated_at"] = datetime.now(timezone.utc)
|
||||
return TranslationSession(**entry["data"])
|
||||
|
||||
def delete(self, session_id: str) -> bool:
|
||||
if session_id in self._store:
|
||||
del self._store[session_id]
|
||||
return True
|
||||
return False
|
||||
|
||||
def cleanup_expired(self):
|
||||
"""Remove all expired sessions."""
|
||||
now = datetime.now(timezone.utc)
|
||||
expired_keys = [
|
||||
k for k, v in self._store.items()
|
||||
if (now - v["updated_at"]) > timedelta(hours=self.ttl_hours)
|
||||
]
|
||||
for k in expired_keys:
|
||||
del self._store[k]
|
||||
|
||||
|
||||
# Singleton instance
|
||||
session_store = SessionStore(ttl_hours=24)
|
||||
Loading…
Add table
Add a link
Reference in a new issue