Improve translation reliability and progress feedback

This commit is contained in:
burnintuna 2026-07-30 17:07:02 +09:00
parent 9439858b11
commit 51a6e845d1
20 changed files with 1194 additions and 216 deletions

View file

@ -1,6 +1,9 @@
# JWT 시그니용 키 (변경 권장)
JWT_SECRET_KEY=my-super-secret-key-change-me
# JWT_SECRET_KEY 미설정 시 생성할 키 파일 (기본: config/.jwt-secret)
# JWT_SECRET_FILE=config/.jwt-secret
# LLM 설정 파일 경로 (기본: config/llms.json)
LLMS_CONFIG_PATH=config/llms.json
@ -15,3 +18,7 @@ PORT=8000
# 프론트엔드 빌드 폴더 경로 (기본: frontend/dist)
FRONTEND_DIST_PATH=frontend/dist
# 개별 LLM 요청 제한 시간(초)과 장문 분할 크기(문자 수)
LLM_TIMEOUT_SECONDS=180
TRANSLATION_CHUNK_CHARS=1500

1
.gitignore vendored
View file

@ -12,6 +12,7 @@ frontend/dist/
# Env files
.env
config/.jwt-secret
# IDE
.vscode/

View file

@ -19,7 +19,7 @@ Single-server deployment: `uvicorn backend.main:app` serves both API and fronten
| Auth | JWT (PyJWT), bcrypt (direct via `bcrypt` module) |
| LLM Client | OpenAI SDK (`openai`) — OpenAI-compatible API format |
| Frontend | Vue 3 + Composition API `<script setup>`, TypeScript, Pinia |
| Styling | Tailwind CSS v4 with `darkMode: 'class'` strategy |
| Styling | Tailwind CSS v3 with `darkMode: 'class'` strategy |
| Build | Vite (frontend), no bundler for backend |
## Directory Structure
@ -55,10 +55,11 @@ LLM-translator/
│ └── package.json
├── config/
│ ├── llms.json # LLM server configs [{alias, base_url, api_key, model, context_size}]
│ └── users.json # Users [{id, password_hash}] — hashed on first startup from plain-text
│ └── users.json # Users [{id, password}] — password_plain is migrated on startup
├── requirements.txt # Python deps: fastapi, uvicorn[standard], pyjwt, bcrypt, openai, python-dotenv
├── .env.example # Environment variables template
└── README.md # (TBD)
├── tests/ # Backend API and session regression tests
└── README.md # Setup and operation guide
```
## Key Patterns & Conventions
@ -66,9 +67,12 @@ LLM-translator/
### Backend
- **No `passlib`** — use `bcrypt` module directly (`bcrypt.hashpw`, `bcrypt.checkpw`). The project uses Python 3.14 which has compatibility issues with passlib's bcrypt wrapper.
- **LLM client caching**`llm_client.py` maintains a per-alias `{AsyncOpenAI, LLMConfig}` cache dict keyed by alias string. Clients are lazily instantiated on first use.
- **Session store**simple in-memory dict (`sessions.py`). Key is UUID hex (16 chars). TTL 24h from last access. No persistence beyond process lifetime.
- **LLM client caching**`llm_client.py` caches an `AsyncOpenAI` client by alias. Clients are lazily instantiated on first use.
- **Session store**process-local in-memory dict (`sessions.py`) scoped by authenticated owner. Key is UUID hex (16 chars). TTL is 24h from last access; there is no persistence beyond process lifetime.
- **JSON extraction** — LLM responses may be wrapped in markdown code blocks. `_extract_json()` strips ```` ```json` ... ```` wrappers before `json.loads()`.
- **Long-text chunking** — phases 1, 3, and 4 process text sequentially near sentence boundaries. Defaults are 1,500 characters per chunk and a 180-second timeout per LLM call; both are configurable by environment variables.
- **Progress feedback** — LLM calls stream response deltas into transient session progress. The frontend polls `/api/sessions/{session_id}/progress` and displays the current chunk and a rolling preview.
- **Malformed Phase 1 JSON** — parsing tolerates fences, surrounding prose, alternate translation keys, missing noun fields, and trailing commas. Remaining failures trigger one JSON-repair call and then a plain-translation fallback.
### Frontend
@ -82,7 +86,7 @@ LLM-translator/
Models are defined in `config/llms.json`. Each entry:
```json
{
"alias": "Qwen3.6-35B", // Display name, used as modelPhase1..4 values
"alias": "Qwen3.6-35B", // Display name, used by LLM phases 1, 3, and 4
"base_url": "http://...", // OpenAI-compatible base URL (must end with /v1)
"api_key": "...", // API key for this server
"model": "hx370/qwen3.6-35b-a3b", // Exact model name as used by the LLM provider
@ -105,7 +109,7 @@ source_text ──► Phase1(LLM) ──► {translated, proper_nouns[], summary
phase4_result (final)
```
Phase buttons are always freely executable — user can jump to any phase independently. Dependencies are only logical: P2 needs P1 data, P3 needs P1 result, P4 needs P3 result.
Phase dependencies are enforced: Phase 2 and 3 require Phase 1, and Phase 4 requires Phase 3. Running Phase 3 automatically submits the current Phase 2 edits.
### Theme / Dark Mode
@ -121,7 +125,7 @@ pip install -r requirements.txt
cd frontend && npm install && cd ..
# 2. Build frontend (required before serving in production mode)
cd frontend && npx vite build && cd ..
cd frontend && npm run build && cd ..
# 3. Configure LLM servers and users
# Edit config/llms.json and config/users.json directly
@ -142,10 +146,12 @@ JWT_SECRET_KEY=your-secret uvicorn backend.main:app --reload
2. Add API endpoint in `backend/main.py` following the existing pattern:
```python
@app.post("/api/translate/{session_id}/phaseN")
async def run_phaseN(session_id: str):
session = session_store.get(session_id)
async def run_phaseN(session_id: str, user_id: str = Depends(get_current_user)):
session = session_store.get(session_id, user_id)
# ... call chat_complete(alias, system_prompt, user_content)
updated = session_store.update(session_id, {"phaseN_result": result_text})
updated = session_store.update(
session_id, {"phaseN_result": result_text}, user_id
)
return SessionResponse(session_id=session_id, data=updated).model_dump()
```
3. Add frontend component and button in `TranslatorView.vue`

View file

@ -5,7 +5,7 @@
## 특징
- **4단계 번역 파이프라인**: 초벌번역 → 고유명사/문체 분석 → 재번역 → 마무리 다듬기
- **Phase별 별도 LLM 모델 선택**: 각 단계마다 다른 모델을 지정 가능
- **LLM 단계별 모델 선택**: Phase 1, 3, 4에 서로 다른 모델 지정 가능
- **고유명사 관리**: 추출된 고유명사를 사용자가 검토·수정 가능
- **세로 3열 비교 UI**: Phase 1 / Phase 3 / Phase 4 결과를 한눈에 비교
- **다크모드 지원**: 시스템 설정 자동 감지 + 수동 토글 (localStorage 지속화)
@ -20,7 +20,7 @@ pip install -r requirements.txt
cd frontend && npm install && cd ..
# 2. 프론트엔드 빌드 (프로덕션 모드)
npx vite build --prefix-links=false
cd frontend && npm run build && cd ..
# 3. 서버 실행
uvicorn backend.main:app --host 0.0.0.0 --port 8000
@ -58,11 +58,14 @@ uvicorn backend.main:app --host 0.0.0.0 --port 8000
| 변수 | 설명 | 기본값 |
|------|------|--------|
| `JWT_SECRET_KEY` | JWT 서명 키 | `dev-secret-key-change-in-production` |
| `JWT_SECRET_KEY` | JWT 서명 키 | 미설정 시 키 파일 자동 생성 |
| `JWT_SECRET_FILE` | 서명 키 자동 생성 파일 | `config/.jwt-secret` |
| `JWT_EXPIRE_HOURS` | 토큰 만료 시간(시간) | `24` |
| `LLMS_CONFIG_PATH` | LLM 설정 파일 경로 | `config/llms.json` |
| `USERS_CONFIG_PATH` | 사용자 설정 파일 경로 | `config/users.json` |
| `PORT` | 서버 포트 | `8000` |
| `LLM_TIMEOUT_SECONDS` | 개별 LLM 호출 제한 시간 | `180` |
| `TRANSLATION_CHUNK_CHARS` | 장문 분할 목표 크기 | `1500` |
## 프로젝트 구조
@ -92,7 +95,7 @@ cd frontend && npx vite # http://localhost:5173
|--------|------|------|------|
| `POST` | `/api/auth/login` | 로그인 → JWT 토큰 발급 | × |
| `GET` | `/api/auth/me` | 현재 사용자 정보 | ○ (JWT) |
| `GET` | `/api/models` | 사용 가능한 LLM 모델 목록 | × |
| `GET` | `/api/models` | 사용 가능한 LLM 모델 목록 | |
| `POST` | `/api/translate/create` | 새 번역 세션 생성 | ○ |
| `GET` | `/api/sessions/{id}` | 세션 상태 조회 | ○ |
| `PATCH`| `/api/sessions/{id}` | 세션 필드 부분 업데이트 | ○ |
@ -105,7 +108,7 @@ cd frontend && npx vite # http://localhost:5173
```bash
# 1. 프론트엔드 빌드
cd frontend && npx vite build && cd ..
cd frontend && npm run build && cd ..
# 2. 백엔드 서빙 (dist 정적 파일 포함)
uvicorn backend.main:app --host 0.0.0.0 --port 8000
@ -113,7 +116,17 @@ uvicorn backend.main:app --host 0.0.0.0 --port 8000
`backend/main.py``frontend/dist/` 폴더를 정적 파일로 마운트하므로, 빌드 후 백엔드 서버 하나만 실행하면 됩니다.
## 테스트 (LLM 서버 연결 확인)
장문은 문장 또는 줄 경계를 우선하여 기본 1,500자 단위로 나누고 Phase 1, 3, 4에서 순차 처리합니다. 공개 LLM 서버가 혼잡하면 `.env`에서 `LLM_TIMEOUT_SECONDS`를 늘리거나 `TRANSLATION_CHUNK_CHARS`를 줄일 수 있습니다.
LLM 처리 중에는 현재 chunk 번호와 스트리밍으로 수신 중인 최근 내용이 화면에 임시 표시됩니다. Phase 1 JSON이 불완전하면 코드 블록·주변 문구·후행 쉼표를 먼저 보정하고, 실패 시 LLM JSON 복구를 거쳐 일반 번역 결과로 폴백합니다.
## 자동 테스트
```bash
python -m unittest discover -v
```
## LLM 서버 연결 확인
```bash
curl -s http://btuna.net:45455/v1/chat/completions \

View file

@ -1,21 +1,62 @@
"""JWT-based authentication with config-file user management."""
import os
import logging
import secrets
from datetime import datetime, timedelta, timezone
from pathlib import Path
import bcrypt
import jwt
from dotenv import load_dotenv
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")
PROJECT_ROOT = Path(__file__).parent.parent
load_dotenv(PROJECT_ROOT / ".env")
def _load_jwt_secret() -> str:
configured = os.getenv("JWT_SECRET_KEY")
if configured:
return configured
secret_path = Path(
os.getenv("JWT_SECRET_FILE", str(PROJECT_ROOT / "config" / ".jwt-secret"))
)
try:
secret = secret_path.read_text(encoding="utf-8").strip()
if secret:
return secret
except FileNotFoundError:
pass
secret = secrets.token_urlsafe(48)
try:
descriptor = os.open(secret_path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
except FileExistsError:
secret = secret_path.read_text(encoding="utf-8").strip()
if secret:
return secret
raise RuntimeError(f"JWT secret file is empty: {secret_path}")
except OSError as exc:
raise RuntimeError(
"Set JWT_SECRET_KEY because the JWT secret file could not be created: "
f"{secret_path}"
) from exc
with os.fdopen(descriptor, "w", encoding="utf-8") as secret_file:
secret_file.write(secret)
logging.getLogger(__name__).warning("Created JWT secret file at %s", secret_path)
return secret
JWT_SECRET_KEY = _load_jwt_secret()
JWT_ALGORITHM = "HS256"
JWT_EXPIRE_HOURS = int(os.getenv("JWT_EXPIRE_HOURS", "24"))
security = HTTPBearer()
security = HTTPBearer(auto_error=False)
# ── User Loading ───────────────────────────────────────
@ -74,12 +115,20 @@ def decode_access_token(token: str) -> dict | None:
# ── Dependency ───────────────────────────────────────
async def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)) -> str:
async def get_current_user(
credentials: HTTPAuthorizationCredentials | None = Depends(security),
) -> str:
"""Extract and validate JWT, returning the user_id."""
if credentials is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="인증이 필요합니다",
)
payload = decode_access_token(credentials.credentials)
if payload is None or "user_id" not in payload:
user_id = payload.get("user_id") if payload else None
if not isinstance(user_id, str) or get_user_by_username(user_id) is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="유효하지 않거나 만료된 토큰입니다",
)
return payload["user_id"]
return user_id

View file

@ -2,6 +2,7 @@
import os
from pathlib import Path
from collections.abc import Awaitable, Callable
from openai import AsyncOpenAI
@ -22,6 +23,7 @@ def _load_llm_configs() -> list[LLMConfig]:
_llm_configs: list[LLMConfig] = []
_clients: dict[str, AsyncOpenAI] = {} # alias -> client instance
LLM_TIMEOUT_SECONDS = float(os.getenv("LLM_TIMEOUT_SECONDS", "180"))
def get_llm_configs() -> list[LLMConfig]:
@ -46,7 +48,8 @@ def get_client_for_alias(alias: str) -> tuple[AsyncOpenAI, LLMConfig]:
client = AsyncOpenAI(
api_key=cfg.api_key,
base_url=cfg.base_url,
timeout=120.0,
timeout=LLM_TIMEOUT_SECONDS,
max_retries=0,
)
_clients[alias] = client
@ -58,22 +61,34 @@ async def chat_complete(
system_prompt: str,
user_content: str,
temperature: float = 0.3,
on_delta: Callable[[str], Awaitable[None]] | None = None,
) -> 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=[
request = {
"model": cfg.model,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_content},
],
temperature=temperature,
)
"temperature": temperature,
}
if on_delta is None:
response = await client.chat.completions.create(**request)
content = response.choices[0].message.content or ""
return content.strip()
stream = await client.chat.completions.create(**request, stream=True)
parts: list[str] = []
async for event in stream:
if not event.choices:
continue
delta = event.choices[0].delta.content or ""
if delta:
parts.append(delta)
await on_delta(delta)
return "".join(parts).strip()
def get_context_size(alias: str) -> int | None:
"""Get the context window size for a given alias."""

View file

@ -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(
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
)
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", ""),
_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 = 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
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(
phase1_translated=session.phase1_result.translated,
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 if session.phase1_result else ""),
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) ───────────────────

View file

@ -42,6 +42,20 @@ class Phase1Result(BaseModel):
proper_nouns: list[ProperNoun] # 추출된 고유명사 리스트
summary: str # 내용 요약
style: str # 문체 분석 결과
warnings: list[str] = [] # JSON 복구/폴백 등 품질 관련 알림
class TranslationChunk(BaseModel):
source_text: str
translated: str
class PhaseProgress(BaseModel):
phase: int
chunk: int
total_chunks: int
status: str
preview: str = ""
class TranslationSession(BaseModel):
@ -55,10 +69,13 @@ class TranslationSession(BaseModel):
model_phase3: str = "" # Phase 3 LLM 모델 alias
model_phase4: str = "" # Phase 4 LLM 모델 alias
phase1_result: Phase1Result | None = None # 초벌 번역 결과
phase1_chunks: list[TranslationChunk] = [] # 장문 분할 시 원문/초벌 번역 쌍
phase2_proper_nouns: list[ProperNoun] = [] # Phase 2에서 최종 확정된 고유명사
phase2_style: str = "" # Phase 2에서 최종 확정된 문체
phase3_result: str = "" # 재번역 결과
phase3_chunks: list[str] = [] # 장문 분할 재번역 결과
phase4_result: str = "" # 마무리 다듬기 결과
progress: PhaseProgress | None = None # 장기 작업 진행 상태
# ── API Requests ───────────────────────────────────────

View file

@ -5,13 +5,21 @@
SYSTEM_PROMPT_PHASE1 = """\
당신은 전문 번역가입니다. 사용자의 지시에 따라 원문을 도착 언어로 번역하고, \
고유명사를 추출하며, 내용 요약을 작성하고, 문체를 분석합니다.\
고유명사를 추출하며, 내용 요약을 작성하고, 문체를 분석합니다. 번역할 원문의 \
주어와 목적어, 인칭, 시제, 양태, 높임 수준을 정확히 보존합니다.\
"""
def build_phase1_user_prompt(source_text: str, target_language: str) -> str:
def build_phase1_user_prompt(
source_text: str, source_language: str, target_language: str
) -> str:
source_instruction = (
"원문의 언어를 자동으로 판단하고"
if source_language == "auto"
else f'원문이 "{source_language}"임을 전제로'
)
return f"""\
다음 원문을 "{target_language}"으로 번역하세요.
{source_instruction} 다음 원문을 "{target_language}"으로 번역하세요.
번역 결과를 반드시 아래 JSON 포맷으로만 반환하세요 (마크다운 코드 블록 없이 순수 JSON):
@ -44,13 +52,14 @@ SYSTEM_PROMPT_PHASE3 = """\
def build_phase3_user_prompt(
source_text: str,
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', ''))}"
f" - {pn['original']}{pn.get('final') or pn.get('suggested', '')}"
for pn in proper_nouns if pn.get("original") or pn.get("suggested")
)
@ -65,10 +74,15 @@ def build_phase3_user_prompt(
재번역 규칙:
1. 고유명사 매핑을 반드시 준수하세요
2. 지시된 문체에 맞게 어조를 조정하세요
3. 초벌 번역의 의미는 유지하되 자연스럽고 정확히 다듬으세요
3. 원문을 기준으로 초벌 번역의 누락이나 오역을 바로잡으세요
4. 결과만 반환하세요 (설명 없이 번역문만)
초벌 번역:
원문:
---
{source_text}
---
초벌 번역 참고본:
---
{phase1_translated}
---"""
@ -78,7 +92,8 @@ def build_phase3_user_prompt(
SYSTEM_PROMPT_PHASE4 = """\
당신은 "{target_language}" 원어민 편집자입니다. 제공된 번역문을 \
"{target_language}" 화자에게 읽기 편하도록 다듬으세요.\
"{target_language}" 화자에게 읽기 편하도록 다듬되, 번역문의 의미 관계와 \
인칭, 시제, 양태, 높임 수준은 변경하지 마세요.\
"""
@ -86,9 +101,10 @@ def build_phase4_user_prompt(
phase3_result: str,
proper_nouns: list[dict],
style: str,
target_language: str,
) -> str:
pn_lines = "\n".join(
f" - {pn['original']}{pn.get('final', pn.get('suggested', ''))}"
f" - {pn['original']}{pn.get('final') or pn.get('suggested', '')}"
for pn in proper_nouns if pn.get("original") or pn.get("suggested")
)
@ -102,10 +118,12 @@ def build_phase4_user_prompt(
다듬기 규칙:
1. 고유명사는 절대 변경하지 마세요
2. 한국어 화자에게 자연스럽게 읽히도록 어순과 표현을 조정하세요
2. {target_language} 화자에게 자연스럽게 읽히도록 어순과 표현을 조정하세요
3. 문장은 간결하고 명확하게 다듬으세요
4. 의미는 반드시 유지하세요
5. 결과만 반환하세요 (설명 없이 번역문만)
5. 주어와 목적어, 화자와 청자의 관계를 뒤바꾸지 마세요
6. 인칭, 시제, 희망·추측 등의 양태와 높임 수준을 변경하지 마세요
7. 결과만 반환하세요 (설명 없이 번역문만)
번역문:
---

View file

@ -7,8 +7,12 @@ from typing import Any
from backend.models import TranslationSession
class SessionConflictError(Exception):
"""Raised when a long-running phase tries to update a changed session."""
class SessionStore:
"""Thread-safe-ish in-memory dict for translation sessions.
"""Process-local in-memory store for user-owned translation sessions.
Each session is keyed by a UUID and has a 24-hour TTL.
Expired sessions are lazily cleaned on access.
@ -18,18 +22,26 @@ class SessionStore:
self._store: dict[str, dict[str, Any]] = {}
self.ttl_hours = ttl_hours
def create(self, session_data: TranslationSession) -> str:
def create(self, session_data: TranslationSession, owner_id: str) -> str:
session_id = uuid.uuid4().hex[:16]
self._store[session_id] = {
"data": session_data.model_dump(),
"owner_id": owner_id,
"version": 0,
"created_at": datetime.now(timezone.utc),
"updated_at": datetime.now(timezone.utc),
}
return session_id
def get(self, session_id: str) -> TranslationSession | None:
def get(self, session_id: str, owner_id: str) -> TranslationSession | None:
result = self.get_with_version(session_id, owner_id)
return result[0] if result else None
def get_with_version(
self, session_id: str, owner_id: str
) -> tuple[TranslationSession, int] | None:
entry = self._store.get(session_id)
if entry is None:
if entry is None or entry["owner_id"] != owner_id:
return None
# Check TTL
now = datetime.now(timezone.utc)
@ -39,22 +51,51 @@ class SessionStore:
return None
# Touch (refresh TTL)
entry["updated_at"] = now
return TranslationSession(**entry["data"])
return TranslationSession(**entry["data"]), entry["version"]
def update(self, session_id: str, data: dict[str, Any]) -> TranslationSession | None:
def update(
self,
session_id: str,
data: dict[str, Any],
owner_id: str,
expected_version: int | None = None,
) -> TranslationSession | None:
entry = self._store.get(session_id)
if entry is None:
if entry is None or entry["owner_id"] != owner_id:
return None
now = datetime.now(timezone.utc)
if now - entry["updated_at"] > timedelta(hours=self.ttl_hours):
del self._store[session_id]
return None
if expected_version is not None and entry["version"] != expected_version:
raise SessionConflictError
entry["data"].update(data)
entry["updated_at"] = datetime.now(timezone.utc)
entry["version"] += 1
entry["updated_at"] = now
return TranslationSession(**entry["data"])
def delete(self, session_id: str) -> bool:
if session_id in self._store:
def delete(self, session_id: str, owner_id: str) -> bool:
entry = self._store.get(session_id)
if entry is not None and entry["owner_id"] == owner_id:
del self._store[session_id]
return True
return False
def set_progress(
self, session_id: str, progress: dict[str, Any] | None, owner_id: str
) -> bool:
"""Update transient progress without changing the semantic session version."""
entry = self._store.get(session_id)
if entry is None or entry["owner_id"] != owner_id:
return False
now = datetime.now(timezone.utc)
if now - entry["updated_at"] > timedelta(hours=self.ttl_hours):
del self._store[session_id]
return False
entry["data"]["progress"] = progress
entry["updated_at"] = now
return True
def cleanup_expired(self):
"""Remove all expired sessions."""
now = datetime.now(timezone.utc)

View file

@ -1,7 +1,9 @@
/** Axios-based API client with JWT token management */
import axios from 'axios'
import type { LLMModel, SessionResponse } from './types'
import type { LLMModel, PhaseProgress, ProperNoun, SessionConfig, SessionResponse } from './types'
export const AUTH_UNAUTHORIZED_EVENT = 'llm-translator:unauthorized'
const api = axios.create({
baseURL: '/api',
@ -17,13 +19,13 @@ api.interceptors.request.use((config) => {
return config
})
// Auto-redirect on 401
// Notify the auth store so App.vue can switch views without a router.
api.interceptors.response.use(
(res) => res,
(err) => {
if (err.response?.status === 401) {
localStorage.removeItem('jwt_token')
window.location.hash = '#/login'
window.dispatchEvent(new Event(AUTH_UNAUTHORIZED_EVENT))
}
return Promise.reject(err)
},
@ -56,15 +58,7 @@ export async function listModels(): Promise<LLMModel[]> {
// ── Sessions ─────────────────────────────────────────
export async function createSession(params: {
source_text: string
source_language: string
target_language: string
model_phase1: string
model_phase2: string
model_phase3: string
model_phase4: string
}): Promise<{ session_id: string }> {
export async function createSession(params: SessionConfig): Promise<{ session_id: string }> {
const res = await api.post('/translate/create', params)
return res.data
}
@ -74,13 +68,20 @@ export async function getSession(sessionId: string): Promise<SessionResponse> {
return res.data
}
export async function getSessionProgress(
sessionId: string,
): Promise<{ progress: PhaseProgress | null }> {
const res = await api.get(`/sessions/${sessionId}/progress`)
return res.data
}
export async function deleteSession(sessionId: string): Promise<void> {
await api.delete(`/sessions/${sessionId}`)
}
export async function updateSession(
sessionId: string,
patch: Record<string, any>,
patch: Partial<SessionConfig>,
): Promise<SessionResponse> {
const res = await api.patch(`/sessions/${sessionId}`, patch)
return res.data
@ -95,7 +96,7 @@ export async function runPhase1(sessionId: string): Promise<SessionResponse> {
export async function runPhase2(
sessionId: string,
proper_nouns: any[],
proper_nouns: ProperNoun[],
style: string,
): Promise<SessionResponse> {
const res = await api.post(`/translate/${sessionId}/phase2`, { proper_nouns, style })

View file

@ -1,6 +1,6 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { me as apiMe, logout as apiLogout } from '../api'
import { computed, onScopeDispose, ref } from 'vue'
import { AUTH_UNAUTHORIZED_EVENT, me as apiMe, logout as apiLogout } from '../api'
export const useAuthStore = defineStore('auth', () => {
const user_id = ref<string | null>(null)
@ -8,6 +8,14 @@ export const useAuthStore = defineStore('auth', () => {
const isLoggedIn = computed(() => !!user_id.value && loaded.value)
function handleUnauthorized() {
user_id.value = null
loaded.value = true
}
window.addEventListener(AUTH_UNAUTHORIZED_EVENT, handleUnauthorized)
onScopeDispose(() => window.removeEventListener(AUTH_UNAUTHORIZED_EVENT, handleUnauthorized))
async function check() {
if (loaded.value) return
try {
@ -23,7 +31,6 @@ export const useAuthStore = defineStore('auth', () => {
async function logout() {
await apiLogout()
user_id.value = null
window.location.hash = '#/login'
}
return { user_id, loaded, isLoggedIn, check, logout }

View file

@ -1,24 +1,32 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { ProperNoun, TranslationSessionData, HistoryEntry, LLMModel } from '../types'
import { computed, ref, watch } from 'vue'
import { isAxiosError } from 'axios'
import type { HistoryEntry, LLMModel, Phase1Result, PhaseProgress, ProperNoun, SessionConfig, TranslationSessionData } from '../types'
import * as api from '../api'
import { useAuthStore } from './auth'
const HISTORY_KEY = 'llm_translator_history'
const SESSION_KEY_PREFIX = 'llm_translator_session_'
const MODELS_KEY = 'llm_translator_models'
interface SavedModels {
p1?: string
p3?: string
p4?: string
}
export const useTranslationStore = defineStore('translation', () => {
const auth = useAuthStore()
// ── State ────────────────────────────────────────
const sessionId = ref<string | null>(null)
const sourceText = ref('')
const sourceLanguage = ref('auto')
const targetLanguage = ref('한국어')
const modelPhase1 = ref('')
const modelPhase2 = ref('')
const modelPhase3 = ref('')
const modelPhase4 = ref('')
// Phase results
const phase1Result = ref<{ translated: string; proper_nouns: ProperNoun[]; summary: string; style: string } | null>(null)
const phase1Result = ref<Phase1Result | null>(null)
const phase2ProperNouns = ref<ProperNoun[]>([])
const phase2Style = ref('')
const phase3Result = ref('')
@ -30,22 +38,51 @@ export const useTranslationStore = defineStore('translation', () => {
const error = ref<string | null>(null)
const models = ref<LLMModel[]>([])
const history = ref<HistoryEntry[]>([])
const historySnapshot = ref(false)
const progress = ref<PhaseProgress | null>(null)
let progressTimer: ReturnType<typeof setInterval> | null = null
let progressRequestPending = false
// ── Computed ─────────────────────────────────────
const phase1Done = computed(() => !!phase1Result.value)
const phase2Ready = computed(() => phase1Done.value) // can edit after P1
const phase3Ready = computed(() => phase1Done.value && (phase2ProperNouns.value.length > 0 || phase2Style.value))
const phase4Ready = computed(() => !!phase3Result.value)
const activeAbortController = ref<AbortController | null>(null)
const phase2Ready = computed(() => phase1Done.value && !historySnapshot.value)
const phase3Ready = computed(() => phase1Done.value && !historySnapshot.value)
const phase4Ready = computed(() => !!phase3Result.value && !historySnapshot.value)
// ── Actions ──────────────────────────────────────
function getErrorMessage(error: unknown, fallback: string): string {
if (isAxiosError<{ detail?: string }>(error)) {
return error.response?.data?.detail || error.message || fallback
}
return error instanceof Error ? error.message : fallback
}
function loadSavedModels(): SavedModels {
try {
const parsed: unknown = JSON.parse(localStorage.getItem(MODELS_KEY) || '{}')
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {}
const value = parsed as Record<string, unknown>
return {
p1: typeof value.p1 === 'string' ? value.p1 : undefined,
p3: typeof value.p3 === 'string' ? value.p3 : undefined,
p4: typeof value.p4 === 'string' ? value.p4 : undefined,
}
} catch {
return {}
}
}
function validModel(alias: string | undefined, fallback: string): string {
if (models.value.length === 0) return alias || fallback
return alias && models.value.some(model => model.alias === alias) ? alias : fallback
}
async function loadModels() {
try {
models.value = await api.listModels()
} catch (e: any) {
console.error('Failed to load models:', e)
} catch (error: unknown) {
console.error('Failed to load models:', error)
}
}
@ -60,23 +97,46 @@ export const useTranslationStore = defineStore('translation', () => {
phase3Result.value = ''
phase4Result.value = ''
error.value = null
historySnapshot.value = false
// Restore model selections from localStorage
const savedModels = JSON.parse(localStorage.getItem('llm_translator_models') || '{}')
modelPhase1.value = savedModels.p1 || models.value[0]?.alias || ''
modelPhase2.value = savedModels.p2 || models.value[0]?.alias || ''
modelPhase3.value = savedModels.p3 || models.value[0]?.alias || ''
modelPhase4.value = savedModels.p4 || (models.value[1]?.alias || models.value[0]?.alias || '')
const savedModels = loadSavedModels()
const defaultModel = models.value[0]?.alias || ''
modelPhase1.value = validModel(savedModels.p1, defaultModel)
modelPhase3.value = validModel(savedModels.p3, defaultModel)
modelPhase4.value = validModel(savedModels.p4, models.value[1]?.alias || defaultModel)
}
function saveModelSelections() {
localStorage.setItem('llm_translator_models', JSON.stringify({
try {
localStorage.setItem(MODELS_KEY, JSON.stringify({
p1: modelPhase1.value,
p2: modelPhase2.value,
p3: modelPhase3.value,
p4: modelPhase4.value,
}))
} catch (error) {
console.error('Failed to save model selections:', error)
}
}
watch([modelPhase1, modelPhase3, modelPhase4], saveModelSelections)
watch([sourceText, sourceLanguage, targetLanguage, modelPhase1], () => {
phase1Result.value = null
phase2ProperNouns.value = []
phase2Style.value = ''
phase3Result.value = ''
phase4Result.value = ''
}, { flush: 'sync' })
watch(modelPhase3, () => {
phase3Result.value = ''
phase4Result.value = ''
}, { flush: 'sync' })
watch(modelPhase4, () => {
phase4Result.value = ''
}, { flush: 'sync' })
watch([phase2ProperNouns, phase2Style], () => {
phase3Result.value = ''
phase4Result.value = ''
}, { deep: true, flush: 'sync' })
async function ensureSession() {
if (!sessionId.value) {
@ -85,13 +145,13 @@ export const useTranslationStore = defineStore('translation', () => {
}
}
function getSessionParams() {
function getSessionParams(): SessionConfig {
return {
source_text: sourceText.value,
source_language: sourceLanguage.value,
target_language: targetLanguage.value,
model_phase1: modelPhase1.value,
model_phase2: modelPhase2.value,
model_phase2: '',
model_phase3: modelPhase3.value,
model_phase4: modelPhase4.value,
}
@ -99,10 +159,44 @@ export const useTranslationStore = defineStore('translation', () => {
async function syncToServer() {
if (!sessionId.value) return
try {
await api.updateSession(sessionId.value, getSessionParams())
} catch (e: any) {
console.error('Sync failed:', e)
}
async function submitPhase2() {
if (!sessionId.value) throw new Error('세션 생성 실패')
const res = await api.runPhase2(
sessionId.value,
phase2ProperNouns.value.map(pn => ({ ...pn, final: pn.final || pn.suggested })),
phase2Style.value,
)
phase2ProperNouns.value = res.data.phase2_proper_nouns || []
phase2Style.value = res.data.phase2_style || ''
}
async function pollProgress() {
if (!sessionId.value || progressRequestPending) return
progressRequestPending = true
try {
const response = await api.getSessionProgress(sessionId.value)
progress.value = response.progress
} catch {
// The phase request handles user-visible errors; polling is best-effort only.
} finally {
progressRequestPending = false
}
}
function startProgressPolling() {
stopProgressPolling()
progress.value = null
void pollProgress()
progressTimer = setInterval(() => void pollProgress(), 750)
}
function stopProgressPolling() {
if (progressTimer !== null) {
clearInterval(progressTimer)
progressTimer = null
}
}
@ -121,21 +215,25 @@ export const useTranslationStore = defineStore('translation', () => {
loading.value = true
currentPhaseLoading.value = 1
error.value = null
activeAbortController.value = new AbortController()
phase3Result.value = ''
phase4Result.value = ''
try {
await ensureSession()
if (!sessionId.value) throw new Error('세션 생성 실패')
await syncToServer()
startProgressPolling()
const res = await api.runPhase1(sessionId.value)
phase1Result.value = res.data.phase1_result
phase2ProperNouns.value = res.data.phase2_proper_nouns || []
phase2Style.value = res.data.phase2_style || ''
} catch (e: any) {
error.value = e.response?.data?.detail || e.message || 'Phase 1 실행 실패'
historySnapshot.value = false
saveToHistory()
} catch (caughtError: unknown) {
error.value = getErrorMessage(caughtError, 'Phase 1 실행 실패')
} finally {
stopProgressPolling()
loading.value = false
currentPhaseLoading.value = null
activeAbortController.value = null
}
}
@ -146,18 +244,14 @@ export const useTranslationStore = defineStore('translation', () => {
loading.value = true
currentPhaseLoading.value = 2
error.value = null
phase3Result.value = ''
phase4Result.value = ''
try {
const res = await api.runPhase2(
sessionId.value,
phase2ProperNouns.value.map(pn => ({ ...pn, final: pn.final || pn.suggested })),
phase2Style.value,
)
// Update local state from server response
phase2ProperNouns.value = res.data.phase2_proper_nouns || []
phase2Style.value = res.data.phase2_style || ''
} catch (e: any) {
error.value = e.response?.data?.detail || e.message || 'Phase 2 확인 실패'
await submitPhase2()
saveToHistory()
} catch (caughtError: unknown) {
error.value = getErrorMessage(caughtError, 'Phase 2 확인 실패')
} finally {
loading.value = false
currentPhaseLoading.value = null
@ -177,19 +271,25 @@ export const useTranslationStore = defineStore('translation', () => {
loading.value = true
currentPhaseLoading.value = 3
error.value = null
activeAbortController.value = new AbortController()
phase3Result.value = ''
phase4Result.value = ''
try {
await ensureSession()
if (!sessionId.value) throw new Error('세션 생성 실패')
await syncToServer()
await submitPhase2()
saveToHistory()
startProgressPolling()
const res = await api.runPhase3(sessionId.value)
phase3Result.value = res.data.phase3_result || ''
} catch (e: any) {
error.value = e.response?.data?.detail || e.message || 'Phase 3 실행 실패'
saveToHistory()
} catch (caughtError: unknown) {
error.value = getErrorMessage(caughtError, 'Phase 3 실행 실패')
} finally {
stopProgressPolling()
loading.value = false
currentPhaseLoading.value = null
activeAbortController.value = null
}
}
@ -202,19 +302,21 @@ export const useTranslationStore = defineStore('translation', () => {
loading.value = true
currentPhaseLoading.value = 4
error.value = null
activeAbortController.value = new AbortController()
try {
await ensureSession()
if (!sessionId.value) throw new Error('세션 생성 실패')
await syncToServer()
startProgressPolling()
const res = await api.runPhase4(sessionId.value)
phase4Result.value = res.data.phase4_result || ''
} catch (e: any) {
error.value = e.response?.data?.detail || e.message || 'Phase 4 실행 실패'
saveToHistory()
} catch (caughtError: unknown) {
error.value = getErrorMessage(caughtError, 'Phase 4 실행 실패')
} finally {
stopProgressPolling()
loading.value = false
currentPhaseLoading.value = null
activeAbortController.value = null
}
}
@ -239,32 +341,43 @@ export const useTranslationStore = defineStore('translation', () => {
// Keep max 50 entries
if (history.value.length > 50) history.value.pop()
localStorage.setItem(HISTORY_KEY, JSON.stringify(history.value))
try {
localStorage.setItem(historyStorageKey(), JSON.stringify(history.value))
} catch (error) {
console.error('Failed to save translation history:', error)
}
}
function historyStorageKey(): string {
return `${HISTORY_KEY}:${auth.user_id || 'anonymous'}`
}
function loadHistory() {
try {
const raw = localStorage.getItem(HISTORY_KEY) || '[]'
const key = historyStorageKey()
const raw = localStorage.getItem(key) || '[]'
history.value = JSON.parse(raw) as HistoryEntry[]
localStorage.removeItem(HISTORY_KEY)
} catch {
history.value = []
}
}
async function restoreFromHistory(entry: HistoryEntry) {
sessionId.value = entry.id
function restoreFromHistory(entry: HistoryEntry) {
sessionId.value = null
sourceText.value = entry.data.source_text
sourceLanguage.value = entry.data.source_language
targetLanguage.value = entry.data.target_language
modelPhase1.value = entry.data.model_phase1
modelPhase2.value = entry.data.model_phase2
modelPhase3.value = entry.data.model_phase3
modelPhase4.value = entry.data.model_phase4
const defaultModel = models.value[0]?.alias || ''
modelPhase1.value = validModel(entry.data.model_phase1, defaultModel)
modelPhase3.value = validModel(entry.data.model_phase3, defaultModel)
modelPhase4.value = validModel(entry.data.model_phase4, models.value[1]?.alias || defaultModel)
phase1Result.value = entry.data.phase1_result
phase2ProperNouns.value = entry.data.phase2_proper_nouns || []
phase2Style.value = entry.data.phase2_style || ''
phase3Result.value = entry.data.phase3_result || ''
phase4Result.value = entry.data.phase4_result || ''
historySnapshot.value = true
// Save model selections for future sessions
saveModelSelections()
@ -276,7 +389,7 @@ export const useTranslationStore = defineStore('translation', () => {
source_language: sourceLanguage.value,
target_language: targetLanguage.value,
model_phase1: modelPhase1.value,
model_phase2: modelPhase2.value,
model_phase2: '',
model_phase3: modelPhase3.value,
model_phase4: modelPhase4.value,
phase1_result: phase1Result.value || null,
@ -309,7 +422,7 @@ export const useTranslationStore = defineStore('translation', () => {
// Add simple markdown formatting — frontmatter with metadata
const md = `---
source_language: ${sourceLanguage.value}
target_language: ${target_language.value}
target_language: ${targetLanguage.value}
timestamp: ${new Date().toISOString()}
---\n\n${content}`
downloadTxt(filename.replace('.txt', '.md'), md)
@ -318,10 +431,10 @@ timestamp: ${new Date().toISOString()}
return {
// State
sessionId, sourceText, sourceLanguage, targetLanguage,
modelPhase1, modelPhase2, modelPhase3, modelPhase4,
modelPhase1, modelPhase3, modelPhase4,
phase1Result, phase2ProperNouns, phase2Style,
phase3Result, phase4Result,
loading, currentPhaseLoading, error, models, history,
loading, currentPhaseLoading, error, models, history, progress,
// Computed
phase1Done, phase2Ready, phase3Ready, phase4Ready,

View file

@ -17,6 +17,15 @@ export interface Phase1Result {
proper_nouns: ProperNoun[]
summary: string
style: string
warnings?: string[]
}
export interface PhaseProgress {
phase: number
chunk: number
total_chunks: number
status: string
preview: string
}
export interface TranslationSessionData {
@ -28,12 +37,26 @@ export interface TranslationSessionData {
model_phase3: string
model_phase4: string
phase1_result: Phase1Result | null
phase1_chunks?: Array<{ source_text: string; translated: string }>
phase2_proper_nouns: ProperNoun[]
phase2_style: string
phase3_result: string
phase3_chunks?: string[]
phase4_result: string
progress?: PhaseProgress | null
}
export type SessionConfig = Pick<
TranslationSessionData,
| 'source_text'
| 'source_language'
| 'target_language'
| 'model_phase1'
| 'model_phase2'
| 'model_phase3'
| 'model_phase4'
>
export interface SessionResponse {
session_id: string
data: TranslationSessionData

View file

@ -1,5 +1,6 @@
<script setup lang="ts">
import { ref } from 'vue'
import { isAxiosError } from 'axios'
import * as api from '../api'
const id = ref('')
@ -14,8 +15,10 @@ async function handleLogin() {
try {
await api.login(id.value, password.value)
window.location.reload()
} catch (e: any) {
errorMessage.value = e.response?.data?.detail || '로그인에 실패했습니다'
} catch (error: unknown) {
errorMessage.value = isAxiosError<{ detail?: string }>(error)
? error.response?.data?.detail || '로그인에 실패했습니다'
: '로그인에 실패했습니다'
} finally {
loading.value = false
}

View file

@ -106,7 +106,6 @@ function handleNewTranslation() {
<section class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-5">
<div class="flex flex-wrap items-end gap-4">
<LLMSelector label="Phase 1 — 초벌번역" v-model:value="store.modelPhase1" :models="store.models" />
<LLMSelector label="Phase 2 — 고유명사" v-model:value="store.modelPhase2" :models="store.models" />
<LLMSelector label="Phase 3 — 재번역" v-model:value="store.modelPhase3" :models="store.models" />
<LLMSelector label="Phase 4 — 마무리" v-model:value="store.modelPhase4" :models="store.models" />
@ -208,16 +207,32 @@ function handleNewTranslation() {
</div>
<!-- Loading Indicator -->
<div v-if="store.loading && !store.error" class="flex items-center gap-2 text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-900/20 px-4 py-3 rounded-lg text-sm">
<div v-if="store.loading && !store.error" class="text-blue-700 dark:text-blue-300 bg-blue-50 dark:bg-blue-900/20 px-4 py-3 rounded-lg text-sm space-y-3">
<div class="flex items-center gap-2">
<svg class="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
{{ phaseLabel(store.currentPhaseLoading || 0) }} 진행 ...
<span>{{ phaseLabel(store.currentPhaseLoading || 0) }} 진행 </span>
<span v-if="store.progress" class="font-medium">
· 조각 {{ store.progress.chunk }}/{{ store.progress.total_chunks }} · {{ store.progress.status }}
</span>
<span v-else class="text-blue-500 dark:text-blue-400">· 서버 응답 대기 </span>
</div>
<pre
v-if="store.progress?.preview"
class="max-h-44 overflow-y-auto whitespace-pre-wrap break-words rounded-lg border border-blue-200 dark:border-blue-800 bg-white/70 dark:bg-gray-900/60 p-3 text-xs leading-relaxed text-gray-600 dark:text-gray-300"
>{{ store.progress.preview }}</pre>
</div>
<!-- Phase 2: Proper Noun Editor -->
<section v-if="store.phase1Done">
<div
v-if="store.phase1Result?.warnings?.length"
class="mb-4 rounded-xl border border-amber-200 dark:border-amber-800 bg-amber-50 dark:bg-amber-900/20 px-4 py-3 text-sm text-amber-800 dark:text-amber-300"
>
<p v-for="warning in store.phase1Result.warnings" :key="warning">{{ warning }}</p>
</div>
<ProperNounsEditor
:proper_nouns="store.phase2ProperNouns"
:style="store.phase2Style"

2
requirements-dev.txt Normal file
View file

@ -0,0 +1,2 @@
-r requirements.txt
httpx==0.28.*

View file

@ -1,6 +1,6 @@
fastapi==0.115.*
uvicorn[standard]==0.34.*
pyjwt==2.10.*
passlib[bcrypt]==1.7.*
bcrypt>=4.2,<6
openai==1.66.*
python-dotenv==1.0.*

0
tests/__init__.py Normal file
View file

252
tests/test_backend.py Normal file
View file

@ -0,0 +1,252 @@
import json
import unittest
from unittest.mock import AsyncMock, patch
from fastapi.testclient import TestClient
from backend.auth import create_access_token
from backend.llm_client import get_llm_configs
from backend.main import _split_text, app
from backend.models import TranslationSession
from backend.sessions import SessionConflictError, SessionStore, session_store
class ApiTests(unittest.TestCase):
def setUp(self):
session_store._store.clear()
token = create_access_token({"user_id": "admin"})
self.client = TestClient(app, headers={"Authorization": f"Bearer {token}"})
self.model = get_llm_configs()[0].alias
def create_session(self, source_text: str = "Hello") -> str:
response = self.client.post(
"/api/translate/create",
json={
"source_text": source_text,
"source_language": "영어",
"target_language": "한국어",
"model_phase1": self.model,
"model_phase2": "",
"model_phase3": self.model,
"model_phase4": self.model,
},
)
self.assertEqual(response.status_code, 200)
return response.json()["session_id"]
def test_translation_routes_require_authentication(self):
response = TestClient(app).post(
"/api/translate/create", json={"source_text": "Hello"}
)
self.assertEqual(response.status_code, 401)
def test_progress_endpoint_returns_only_transient_progress(self):
session_id = self.create_session()
session_store.set_progress(
session_id,
{
"phase": 1,
"chunk": 2,
"total_chunks": 3,
"status": "수신 중",
"preview": "부분 결과",
},
"admin",
)
response = self.client.get(f"/api/sessions/{session_id}/progress")
self.assertEqual(response.status_code, 200)
self.assertEqual(
response.json(),
{
"progress": {
"phase": 1,
"chunk": 2,
"total_chunks": 3,
"status": "수신 중",
"preview": "부분 결과",
}
},
)
def test_unknown_model_is_rejected(self):
response = self.client.post(
"/api/translate/create",
json={"source_text": "Hello", "model_phase1": "missing-model"},
)
self.assertEqual(response.status_code, 400)
def test_source_update_invalidates_all_results(self):
session_id = self.create_session()
phase1 = json.dumps(
{
"translated": "안녕하세요",
"proper_nouns": [],
"summary": "인사",
"style": "중립적",
},
ensure_ascii=False,
)
responses = [phase1, "다시 번역", "최종 번역"]
with patch("backend.main.chat_complete", new=AsyncMock(side_effect=responses)):
self.assertEqual(
self.client.post(f"/api/translate/{session_id}/phase1").status_code,
200,
)
self.assertEqual(
self.client.post(
f"/api/translate/{session_id}/phase2",
json={"proper_nouns": [], "style": "중립적"},
).status_code,
200,
)
self.assertEqual(
self.client.post(f"/api/translate/{session_id}/phase3").status_code,
200,
)
self.assertEqual(
self.client.post(f"/api/translate/{session_id}/phase4").status_code,
200,
)
response = self.client.patch(
f"/api/sessions/{session_id}", json={"source_text": "Changed"}
)
self.assertEqual(response.status_code, 200)
data = response.json()["data"]
self.assertIsNone(data["phase1_result"])
self.assertEqual(data["phase1_chunks"], [])
self.assertEqual(data["phase2_proper_nouns"], [])
self.assertEqual(data["phase2_style"], "")
self.assertEqual(data["phase3_result"], "")
self.assertEqual(data["phase3_chunks"], [])
self.assertEqual(data["phase4_result"], "")
def test_invalid_phase1_json_falls_back_to_plain_translation(self):
session_id = self.create_session()
mocked = AsyncMock(side_effect=["not json", "still not json", "일반 번역"])
with patch("backend.main.chat_complete", new=mocked):
response = self.client.post(f"/api/translate/{session_id}/phase1")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["data"]["phase1_result"]["translated"], "일반 번역")
self.assertTrue(response.json()["data"]["phase1_result"]["warnings"])
self.assertEqual(mocked.await_count, 3)
def test_phase1_accepts_fenced_json_with_trailing_comma(self):
session_id = self.create_session()
malformed = """응답입니다.
```json
{"translated":"안녕하세요","proper_nouns":[],"summary":"인사","style":"중립적",}
```
"""
mocked = AsyncMock(return_value=malformed)
with patch("backend.main.chat_complete", new=mocked):
response = self.client.post(f"/api/translate/{session_id}/phase1")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["data"]["phase1_result"]["translated"], "안녕하세요")
self.assertEqual(mocked.await_count, 1)
def test_long_phase1_is_split_into_bounded_chunks(self):
source_text = "这是用于测试长文本分割的句子。" * 120
expected_chunks = _split_text(source_text)
self.assertGreater(len(expected_chunks), 1)
self.assertTrue(all(len(chunk) <= 1500 for chunk in expected_chunks))
session_id = self.create_session(source_text)
phase1 = json.dumps(
{
"translated": "긴 문장 번역",
"proper_nouns": [],
"summary": "요약",
"style": "중립적",
},
ensure_ascii=False,
)
mocked = AsyncMock(side_effect=[phase1] * len(expected_chunks))
with patch("backend.main.chat_complete", new=mocked):
response = self.client.post(f"/api/translate/{session_id}/phase1")
self.assertEqual(response.status_code, 200)
self.assertEqual(mocked.await_count, len(expected_chunks))
self.assertEqual(len(response.json()["data"]["phase1_chunks"]), len(expected_chunks))
self.client.post(
f"/api/translate/{session_id}/phase2",
json={"proper_nouns": [], "style": "중립적"},
)
phase3_mock = AsyncMock(side_effect=["재번역 조각"] * len(expected_chunks))
with patch("backend.main.chat_complete", new=phase3_mock):
response = self.client.post(f"/api/translate/{session_id}/phase3")
self.assertEqual(response.status_code, 200)
self.assertEqual(phase3_mock.await_count, len(expected_chunks))
self.assertEqual(len(response.json()["data"]["phase3_chunks"]), len(expected_chunks))
phase4_mock = AsyncMock(side_effect=["완성 조각"] * len(expected_chunks))
with patch("backend.main.chat_complete", new=phase4_mock):
response = self.client.post(f"/api/translate/{session_id}/phase4")
self.assertEqual(response.status_code, 200)
self.assertEqual(phase4_mock.await_count, len(expected_chunks))
self.assertEqual(
response.json()["data"]["phase4_result"].count("완성 조각"),
len(expected_chunks),
)
def test_split_text_prefers_english_sentence_boundary(self):
sentence = "This is an English sentence with several words. "
chunks = _split_text(sentence * 50)
self.assertGreater(len(chunks), 1)
self.assertTrue(all(chunk.endswith(".") for chunk in chunks[:-1]))
self.assertTrue(all(len(chunk) <= 1500 for chunk in chunks))
class SessionStoreTests(unittest.TestCase):
def test_sessions_are_isolated_by_owner(self):
store = SessionStore()
session_id = store.create(TranslationSession(source_text="secret"), "alice")
self.assertIsNotNone(store.get(session_id, "alice"))
self.assertIsNone(store.get(session_id, "bob"))
self.assertIsNone(store.update(session_id, {"source_text": "stolen"}, "bob"))
self.assertFalse(store.delete(session_id, "bob"))
self.assertEqual(store.get(session_id, "alice").source_text, "secret")
def test_stale_phase_update_is_rejected(self):
store = SessionStore()
session_id = store.create(TranslationSession(source_text="first"), "alice")
_, version = store.get_with_version(session_id, "alice")
store.update(session_id, {"source_text": "second"}, "alice")
with self.assertRaises(SessionConflictError):
store.update(
session_id,
{"phase3_result": "stale"},
"alice",
expected_version=version,
)
def test_progress_does_not_change_session_version(self):
store = SessionStore()
session_id = store.create(TranslationSession(source_text="first"), "alice")
_, version = store.get_with_version(session_id, "alice")
self.assertTrue(
store.set_progress(
session_id,
{
"phase": 1,
"chunk": 1,
"total_chunks": 2,
"status": "수신 중",
"preview": "부분 결과",
},
"alice",
)
)
session, current_version = store.get_with_version(session_id, "alice")
self.assertEqual(current_version, version)
self.assertEqual(session.progress.preview, "부분 결과")
if __name__ == "__main__":
unittest.main()