Improve translation reliability and progress feedback
This commit is contained in:
parent
9439858b11
commit
51a6e845d1
20 changed files with 1194 additions and 216 deletions
|
|
@ -1,6 +1,9 @@
|
||||||
# JWT 시그니용 키 (변경 권장)
|
# JWT 시그니용 키 (변경 권장)
|
||||||
JWT_SECRET_KEY=my-super-secret-key-change-me
|
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)
|
# LLM 설정 파일 경로 (기본: config/llms.json)
|
||||||
LLMS_CONFIG_PATH=config/llms.json
|
LLMS_CONFIG_PATH=config/llms.json
|
||||||
|
|
||||||
|
|
@ -15,3 +18,7 @@ PORT=8000
|
||||||
|
|
||||||
# 프론트엔드 빌드 폴더 경로 (기본: frontend/dist)
|
# 프론트엔드 빌드 폴더 경로 (기본: frontend/dist)
|
||||||
FRONTEND_DIST_PATH=frontend/dist
|
FRONTEND_DIST_PATH=frontend/dist
|
||||||
|
|
||||||
|
# 개별 LLM 요청 제한 시간(초)과 장문 분할 크기(문자 수)
|
||||||
|
LLM_TIMEOUT_SECONDS=180
|
||||||
|
TRANSLATION_CHUNK_CHARS=1500
|
||||||
|
|
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -12,6 +12,7 @@ frontend/dist/
|
||||||
|
|
||||||
# Env files
|
# Env files
|
||||||
.env
|
.env
|
||||||
|
config/.jwt-secret
|
||||||
|
|
||||||
# IDE
|
# IDE
|
||||||
.vscode/
|
.vscode/
|
||||||
|
|
|
||||||
28
AGENTS.md
28
AGENTS.md
|
|
@ -19,7 +19,7 @@ Single-server deployment: `uvicorn backend.main:app` serves both API and fronten
|
||||||
| Auth | JWT (PyJWT), bcrypt (direct via `bcrypt` module) |
|
| Auth | JWT (PyJWT), bcrypt (direct via `bcrypt` module) |
|
||||||
| LLM Client | OpenAI SDK (`openai`) — OpenAI-compatible API format |
|
| LLM Client | OpenAI SDK (`openai`) — OpenAI-compatible API format |
|
||||||
| Frontend | Vue 3 + Composition API `<script setup>`, TypeScript, Pinia |
|
| 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 |
|
| Build | Vite (frontend), no bundler for backend |
|
||||||
|
|
||||||
## Directory Structure
|
## Directory Structure
|
||||||
|
|
@ -55,10 +55,11 @@ LLM-translator/
|
||||||
│ └── package.json
|
│ └── package.json
|
||||||
├── config/
|
├── config/
|
||||||
│ ├── llms.json # LLM server configs [{alias, base_url, api_key, model, context_size}]
|
│ ├── 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
|
├── requirements.txt # Python deps: fastapi, uvicorn[standard], pyjwt, bcrypt, openai, python-dotenv
|
||||||
├── .env.example # Environment variables template
|
├── .env.example # Environment variables template
|
||||||
└── README.md # (TBD)
|
├── tests/ # Backend API and session regression tests
|
||||||
|
└── README.md # Setup and operation guide
|
||||||
```
|
```
|
||||||
|
|
||||||
## Key Patterns & Conventions
|
## Key Patterns & Conventions
|
||||||
|
|
@ -66,9 +67,12 @@ LLM-translator/
|
||||||
### Backend
|
### 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.
|
- **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.
|
- **LLM client caching** — `llm_client.py` caches an `AsyncOpenAI` client by alias. 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.
|
- **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()`.
|
- **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
|
### Frontend
|
||||||
|
|
||||||
|
|
@ -82,7 +86,7 @@ LLM-translator/
|
||||||
Models are defined in `config/llms.json`. Each entry:
|
Models are defined in `config/llms.json`. Each entry:
|
||||||
```json
|
```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)
|
"base_url": "http://...", // OpenAI-compatible base URL (must end with /v1)
|
||||||
"api_key": "...", // API key for this server
|
"api_key": "...", // API key for this server
|
||||||
"model": "hx370/qwen3.6-35b-a3b", // Exact model name as used by the LLM provider
|
"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)
|
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
|
### Theme / Dark Mode
|
||||||
|
|
||||||
|
|
@ -121,7 +125,7 @@ pip install -r requirements.txt
|
||||||
cd frontend && npm install && cd ..
|
cd frontend && npm install && cd ..
|
||||||
|
|
||||||
# 2. Build frontend (required before serving in production mode)
|
# 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
|
# 3. Configure LLM servers and users
|
||||||
# Edit config/llms.json and config/users.json directly
|
# 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:
|
2. Add API endpoint in `backend/main.py` following the existing pattern:
|
||||||
```python
|
```python
|
||||||
@app.post("/api/translate/{session_id}/phaseN")
|
@app.post("/api/translate/{session_id}/phaseN")
|
||||||
async def run_phaseN(session_id: str):
|
async def run_phaseN(session_id: str, user_id: str = Depends(get_current_user)):
|
||||||
session = session_store.get(session_id)
|
session = session_store.get(session_id, user_id)
|
||||||
# ... call chat_complete(alias, system_prompt, user_content)
|
# ... 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()
|
return SessionResponse(session_id=session_id, data=updated).model_dump()
|
||||||
```
|
```
|
||||||
3. Add frontend component and button in `TranslatorView.vue`
|
3. Add frontend component and button in `TranslatorView.vue`
|
||||||
|
|
|
||||||
25
README.md
25
README.md
|
|
@ -5,7 +5,7 @@
|
||||||
## 특징
|
## 특징
|
||||||
|
|
||||||
- **4단계 번역 파이프라인**: 초벌번역 → 고유명사/문체 분석 → 재번역 → 마무리 다듬기
|
- **4단계 번역 파이프라인**: 초벌번역 → 고유명사/문체 분석 → 재번역 → 마무리 다듬기
|
||||||
- **Phase별 별도 LLM 모델 선택**: 각 단계마다 다른 모델을 지정 가능
|
- **LLM 단계별 모델 선택**: Phase 1, 3, 4에 서로 다른 모델 지정 가능
|
||||||
- **고유명사 관리**: 추출된 고유명사를 사용자가 검토·수정 가능
|
- **고유명사 관리**: 추출된 고유명사를 사용자가 검토·수정 가능
|
||||||
- **세로 3열 비교 UI**: Phase 1 / Phase 3 / Phase 4 결과를 한눈에 비교
|
- **세로 3열 비교 UI**: Phase 1 / Phase 3 / Phase 4 결과를 한눈에 비교
|
||||||
- **다크모드 지원**: 시스템 설정 자동 감지 + 수동 토글 (localStorage 지속화)
|
- **다크모드 지원**: 시스템 설정 자동 감지 + 수동 토글 (localStorage 지속화)
|
||||||
|
|
@ -20,7 +20,7 @@ pip install -r requirements.txt
|
||||||
cd frontend && npm install && cd ..
|
cd frontend && npm install && cd ..
|
||||||
|
|
||||||
# 2. 프론트엔드 빌드 (프로덕션 모드)
|
# 2. 프론트엔드 빌드 (프로덕션 모드)
|
||||||
npx vite build --prefix-links=false
|
cd frontend && npm run build && cd ..
|
||||||
|
|
||||||
# 3. 서버 실행
|
# 3. 서버 실행
|
||||||
uvicorn backend.main:app --host 0.0.0.0 --port 8000
|
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` |
|
| `JWT_EXPIRE_HOURS` | 토큰 만료 시간(시간) | `24` |
|
||||||
| `LLMS_CONFIG_PATH` | LLM 설정 파일 경로 | `config/llms.json` |
|
| `LLMS_CONFIG_PATH` | LLM 설정 파일 경로 | `config/llms.json` |
|
||||||
| `USERS_CONFIG_PATH` | 사용자 설정 파일 경로 | `config/users.json` |
|
| `USERS_CONFIG_PATH` | 사용자 설정 파일 경로 | `config/users.json` |
|
||||||
| `PORT` | 서버 포트 | `8000` |
|
| `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 토큰 발급 | × |
|
| `POST` | `/api/auth/login` | 로그인 → JWT 토큰 발급 | × |
|
||||||
| `GET` | `/api/auth/me` | 현재 사용자 정보 | ○ (JWT) |
|
| `GET` | `/api/auth/me` | 현재 사용자 정보 | ○ (JWT) |
|
||||||
| `GET` | `/api/models` | 사용 가능한 LLM 모델 목록 | × |
|
| `GET` | `/api/models` | 사용 가능한 LLM 모델 목록 | ○ |
|
||||||
| `POST` | `/api/translate/create` | 새 번역 세션 생성 | ○ |
|
| `POST` | `/api/translate/create` | 새 번역 세션 생성 | ○ |
|
||||||
| `GET` | `/api/sessions/{id}` | 세션 상태 조회 | ○ |
|
| `GET` | `/api/sessions/{id}` | 세션 상태 조회 | ○ |
|
||||||
| `PATCH`| `/api/sessions/{id}` | 세션 필드 부분 업데이트 | ○ |
|
| `PATCH`| `/api/sessions/{id}` | 세션 필드 부분 업데이트 | ○ |
|
||||||
|
|
@ -105,7 +108,7 @@ cd frontend && npx vite # http://localhost:5173
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. 프론트엔드 빌드
|
# 1. 프론트엔드 빌드
|
||||||
cd frontend && npx vite build && cd ..
|
cd frontend && npm run build && cd ..
|
||||||
|
|
||||||
# 2. 백엔드 서빙 (dist 정적 파일 포함)
|
# 2. 백엔드 서빙 (dist 정적 파일 포함)
|
||||||
uvicorn backend.main:app --host 0.0.0.0 --port 8000
|
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/` 폴더를 정적 파일로 마운트하므로, 빌드 후 백엔드 서버 하나만 실행하면 됩니다.
|
`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
|
```bash
|
||||||
curl -s http://btuna.net:45455/v1/chat/completions \
|
curl -s http://btuna.net:45455/v1/chat/completions \
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,62 @@
|
||||||
"""JWT-based authentication with config-file user management."""
|
"""JWT-based authentication with config-file user management."""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import logging
|
||||||
|
import secrets
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import bcrypt
|
import bcrypt
|
||||||
import jwt
|
import jwt
|
||||||
|
from dotenv import load_dotenv
|
||||||
from fastapi import Depends, HTTPException, status
|
from fastapi import Depends, HTTPException, status
|
||||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||||
|
|
||||||
# ── Config ────────────────────────────────────────────
|
# ── 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_ALGORITHM = "HS256"
|
||||||
JWT_EXPIRE_HOURS = int(os.getenv("JWT_EXPIRE_HOURS", "24"))
|
JWT_EXPIRE_HOURS = int(os.getenv("JWT_EXPIRE_HOURS", "24"))
|
||||||
|
|
||||||
security = HTTPBearer()
|
security = HTTPBearer(auto_error=False)
|
||||||
|
|
||||||
|
|
||||||
# ── User Loading ───────────────────────────────────────
|
# ── User Loading ───────────────────────────────────────
|
||||||
|
|
@ -74,12 +115,20 @@ def decode_access_token(token: str) -> dict | None:
|
||||||
|
|
||||||
# ── Dependency ───────────────────────────────────────
|
# ── 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."""
|
"""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)
|
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(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
detail="유효하지 않거나 만료된 토큰입니다",
|
detail="유효하지 않거나 만료된 토큰입니다",
|
||||||
)
|
)
|
||||||
return payload["user_id"]
|
return user_id
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from collections.abc import Awaitable, Callable
|
||||||
|
|
||||||
from openai import AsyncOpenAI
|
from openai import AsyncOpenAI
|
||||||
|
|
||||||
|
|
@ -22,6 +23,7 @@ def _load_llm_configs() -> list[LLMConfig]:
|
||||||
|
|
||||||
_llm_configs: list[LLMConfig] = []
|
_llm_configs: list[LLMConfig] = []
|
||||||
_clients: dict[str, AsyncOpenAI] = {} # alias -> client instance
|
_clients: dict[str, AsyncOpenAI] = {} # alias -> client instance
|
||||||
|
LLM_TIMEOUT_SECONDS = float(os.getenv("LLM_TIMEOUT_SECONDS", "180"))
|
||||||
|
|
||||||
|
|
||||||
def get_llm_configs() -> list[LLMConfig]:
|
def get_llm_configs() -> list[LLMConfig]:
|
||||||
|
|
@ -46,7 +48,8 @@ def get_client_for_alias(alias: str) -> tuple[AsyncOpenAI, LLMConfig]:
|
||||||
client = AsyncOpenAI(
|
client = AsyncOpenAI(
|
||||||
api_key=cfg.api_key,
|
api_key=cfg.api_key,
|
||||||
base_url=cfg.base_url,
|
base_url=cfg.base_url,
|
||||||
timeout=120.0,
|
timeout=LLM_TIMEOUT_SECONDS,
|
||||||
|
max_retries=0,
|
||||||
)
|
)
|
||||||
_clients[alias] = client
|
_clients[alias] = client
|
||||||
|
|
||||||
|
|
@ -58,22 +61,34 @@ async def chat_complete(
|
||||||
system_prompt: str,
|
system_prompt: str,
|
||||||
user_content: str,
|
user_content: str,
|
||||||
temperature: float = 0.3,
|
temperature: float = 0.3,
|
||||||
|
on_delta: Callable[[str], Awaitable[None]] | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Send a single-turn chat completion and return the assistant message."""
|
"""Send a single-turn chat completion and return the assistant message."""
|
||||||
client, cfg = get_client_for_alias(alias)
|
client, cfg = get_client_for_alias(alias)
|
||||||
|
request = {
|
||||||
response = await client.chat.completions.create(
|
"model": cfg.model,
|
||||||
model=cfg.model,
|
"messages": [
|
||||||
messages=[
|
|
||||||
{"role": "system", "content": system_prompt},
|
{"role": "system", "content": system_prompt},
|
||||||
{"role": "user", "content": user_content},
|
{"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 ""
|
content = response.choices[0].message.content or ""
|
||||||
return content.strip()
|
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:
|
def get_context_size(alias: str) -> int | None:
|
||||||
"""Get the context window size for a given alias."""
|
"""Get the context window size for a given alias."""
|
||||||
|
|
|
||||||
533
backend/main.py
533
backend/main.py
|
|
@ -1,12 +1,17 @@
|
||||||
"""FastAPI application — main entry point for the LLM Translator backend."""
|
"""FastAPI application — main entry point for the LLM Translator backend."""
|
||||||
|
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
|
from collections import Counter
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from time import monotonic
|
||||||
|
|
||||||
from fastapi import Depends, FastAPI, HTTPException
|
from fastapi import Depends, FastAPI, HTTPException
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.staticfiles import StaticFiles
|
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 import auth as auth_mod
|
||||||
from backend.auth import get_current_user
|
from backend.auth import get_current_user
|
||||||
|
|
@ -24,22 +29,27 @@ from backend.models import (
|
||||||
ProperNoun,
|
ProperNoun,
|
||||||
SessionResponse,
|
SessionResponse,
|
||||||
TokenResponse,
|
TokenResponse,
|
||||||
|
TranslationChunk,
|
||||||
UserInfo,
|
UserInfo,
|
||||||
TranslationSession,
|
TranslationSession,
|
||||||
)
|
)
|
||||||
from backend.prompts import (
|
from backend.prompts import (
|
||||||
SYSTEM_PROMPT_PHASE1,
|
SYSTEM_PROMPT_PHASE1,
|
||||||
SYSTEM_PROMPT_PHASE3,
|
SYSTEM_PROMPT_PHASE3,
|
||||||
|
SYSTEM_PROMPT_PHASE4,
|
||||||
build_phase1_user_prompt,
|
build_phase1_user_prompt,
|
||||||
build_phase3_user_prompt,
|
build_phase3_user_prompt,
|
||||||
build_phase4_user_prompt,
|
build_phase4_user_prompt,
|
||||||
)
|
)
|
||||||
from backend.sessions import session_store
|
from backend.sessions import SessionConflictError, session_store
|
||||||
|
|
||||||
|
|
||||||
# ── App Setup ────────────────────────────────────────
|
# ── App Setup ────────────────────────────────────────
|
||||||
|
|
||||||
app = FastAPI(title="LLM Translator", version="0.1.0")
|
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)
|
# CORS (frontend same-origin by default, allow dev origins)
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
|
|
@ -57,9 +67,13 @@ app.add_middleware(
|
||||||
async def login(req: LoginRequest):
|
async def login(req: LoginRequest):
|
||||||
import bcrypt as _bcrypt
|
import bcrypt as _bcrypt
|
||||||
user = auth_mod.get_user_by_username(req.id)
|
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")
|
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 또는 비밀번호가 틀렸습니다")
|
raise HTTPException(status_code=401, detail="ID 또는 비밀번호가 틀렸습니다")
|
||||||
|
|
||||||
token = auth_mod.create_access_token({"user_id": user["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 ────────────────────────────────
|
# ── LLM Config Routes ────────────────────────────────
|
||||||
|
|
||||||
@app.get("/api/models")
|
@app.get("/api/models")
|
||||||
async def list_models():
|
async def list_models(_user_id: str = Depends(get_current_user)):
|
||||||
"""Return available LLM model configurations."""
|
"""Return available LLM model configurations."""
|
||||||
configs = get_llm_configs()
|
configs = get_llm_configs()
|
||||||
return [
|
return [
|
||||||
|
|
@ -90,7 +104,10 @@ async def list_models():
|
||||||
# ── Session Routes ───────────────────────────────────
|
# ── Session Routes ───────────────────────────────────
|
||||||
|
|
||||||
@app.post("/api/translate/create")
|
@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(
|
session_data = TranslationSession(
|
||||||
source_text=req.source_text,
|
source_text=req.source_text,
|
||||||
source_language=req.source_language,
|
source_language=req.source_language,
|
||||||
|
|
@ -100,30 +117,71 @@ async def create_session(req: CreateSessionRequest):
|
||||||
model_phase3=req.model_phase3,
|
model_phase3=req.model_phase3,
|
||||||
model_phase4=req.model_phase4,
|
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}
|
return {"session_id": session_id}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/sessions/{session_id}")
|
@app.get("/api/sessions/{session_id}")
|
||||||
async def get_session(session_id: str):
|
async def get_session(session_id: str, user_id: str = Depends(get_current_user)):
|
||||||
data = session_store.get(session_id)
|
data = session_store.get(session_id, user_id)
|
||||||
if data is None:
|
if data is None:
|
||||||
raise HTTPException(status_code=404, detail="세션을 찾을 수 없습니다")
|
raise HTTPException(status_code=404, detail="세션을 찾을 수 없습니다")
|
||||||
return SessionResponse(session_id=session_id, data=data).model_dump()
|
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}")
|
@app.delete("/api/sessions/{session_id}")
|
||||||
async def delete_session(session_id: str):
|
async def delete_session(session_id: str, user_id: str = Depends(get_current_user)):
|
||||||
deleted = session_store.delete(session_id)
|
deleted = session_store.delete(session_id, user_id)
|
||||||
if not deleted:
|
if not deleted:
|
||||||
raise HTTPException(status_code=404, detail="세션을 찾을 수 없습니다")
|
raise HTTPException(status_code=404, detail="세션을 찾을 수 없습니다")
|
||||||
return {"deleted": True}
|
return {"deleted": True}
|
||||||
|
|
||||||
|
|
||||||
@app.patch("/api/sessions/{session_id}")
|
@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)
|
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:
|
if updated is None:
|
||||||
raise HTTPException(status_code=404, detail="세션을 찾을 수 없습니다")
|
raise HTTPException(status_code=404, detail="세션을 찾을 수 없습니다")
|
||||||
return SessionResponse(session_id=session_id, data=updated).model_dump()
|
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 ───────────────────────────
|
# ── Phase Execution Routes ───────────────────────────
|
||||||
|
|
||||||
@app.post("/api/translate/{session_id}/phase1")
|
@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."""
|
"""Run Phase 1: rough translation + proper noun extraction + summary + style analysis."""
|
||||||
session = session_store.get(session_id)
|
snapshot = session_store.get_with_version(session_id, user_id)
|
||||||
if session is None:
|
if snapshot is None:
|
||||||
raise HTTPException(status_code=404, detail="세션을 찾을 수 없습니다")
|
raise HTTPException(status_code=404, detail="세션을 찾을 수 없습니다")
|
||||||
|
session, version = snapshot
|
||||||
|
|
||||||
alias = session.model_phase1
|
alias = session.model_phase1
|
||||||
if not alias:
|
if not alias:
|
||||||
raise HTTPException(status_code=400, detail="Phase 1 LLM 모델을 선택하세요")
|
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)
|
chunk_results: list[tuple[str, Phase1Result]] = []
|
||||||
ctx_size = get_context_size(alias)
|
source_chunks = _split_text(session.source_text)
|
||||||
if ctx_size and len(session.source_text) > ctx_size * 0.7:
|
for chunk_index, source_chunk in enumerate(source_chunks, start=1):
|
||||||
raise HTTPException(
|
user_prompt = build_phase1_user_prompt(
|
||||||
status_code=400,
|
source_chunk, session.source_language, session.target_language
|
||||||
detail=f"원문이 너무 깁니다 (최대 {ctx_size} 컨텍스트의 70% 이내로 줄여주세요)",
|
|
||||||
)
|
)
|
||||||
|
_ensure_context_capacity(alias, f"{SYSTEM_PROMPT_PHASE1}\n{user_prompt}")
|
||||||
user_prompt = build_phase1_user_prompt(session.source_text, session.target_language)
|
raw_response = await _chat_complete(
|
||||||
raw_response = await chat_complete(alias, SYSTEM_PROMPT_PHASE1, user_prompt)
|
alias,
|
||||||
|
SYSTEM_PROMPT_PHASE1,
|
||||||
# Parse JSON response — extract from code blocks if wrapped
|
user_prompt,
|
||||||
import json
|
session_id=session_id,
|
||||||
cleaned = _extract_json(raw_response)
|
user_id=user_id,
|
||||||
parsed = json.loads(cleaned)
|
phase=1,
|
||||||
|
chunk_index=chunk_index,
|
||||||
result = Phase1Result(
|
total_chunks=len(source_chunks),
|
||||||
translated=parsed.get("translated", ""),
|
status="초벌 번역 수신 중",
|
||||||
proper_nouns=[ProperNoun(**pn) for pn in parsed.get("proper_nouns", [])],
|
|
||||||
summary=parsed.get("summary", ""),
|
|
||||||
style=parsed.get("style", ""),
|
|
||||||
)
|
)
|
||||||
|
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 session with Phase 1 results and initialize Phase 2 data
|
||||||
update_data = {
|
update_data = {
|
||||||
"phase1_result": result.model_dump(),
|
"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_proper_nouns": [pn.model_dump() for pn in result.proper_nouns],
|
||||||
"phase2_style": result.style,
|
"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:
|
if updated is None:
|
||||||
raise HTTPException(status_code=404, detail="세션을 찾을 수 없습니다")
|
raise HTTPException(status_code=404, detail="세션을 찾을 수 없습니다")
|
||||||
|
|
||||||
|
|
@ -179,17 +260,25 @@ async def run_phase1(session_id: str):
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/translate/{session_id}/phase2")
|
@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."""
|
"""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:
|
if session is None:
|
||||||
raise HTTPException(status_code=404, detail="세션을 찾을 수 없습니다")
|
raise HTTPException(status_code=404, detail="세션을 찾을 수 없습니다")
|
||||||
|
|
||||||
update_data = {
|
update_data = {
|
||||||
"phase2_proper_nouns": [pn.model_dump() for pn in req.proper_nouns],
|
"phase2_proper_nouns": [pn.model_dump() for pn in req.proper_nouns],
|
||||||
"phase2_style": req.style,
|
"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:
|
if updated is None:
|
||||||
raise HTTPException(status_code=404, detail="세션을 찾을 수 없습니다")
|
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")
|
@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."""
|
"""Run Phase 3: re-translation with proper noun and style constraints."""
|
||||||
session = session_store.get(session_id)
|
snapshot = session_store.get_with_version(session_id, user_id)
|
||||||
if session is None:
|
if snapshot is None:
|
||||||
raise HTTPException(status_code=404, detail="세션을 찾을 수 없습니다")
|
raise HTTPException(status_code=404, detail="세션을 찾을 수 없습니다")
|
||||||
|
session, version = snapshot
|
||||||
|
|
||||||
if not session.phase1_result:
|
if not session.phase1_result:
|
||||||
raise HTTPException(status_code=400, detail="먼저 Phase 1을 실행하세요")
|
raise HTTPException(status_code=400, detail="먼저 Phase 1을 실행하세요")
|
||||||
|
|
||||||
alias = session.model_phase3 or session.model_phase1
|
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(
|
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],
|
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,
|
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": "\n\n".join(result_chunks),
|
||||||
update_data = {"phase3_result": result_text}
|
"phase3_chunks": result_chunks,
|
||||||
updated = session_store.update(session_id, update_data)
|
"phase4_result": "",
|
||||||
|
"progress": None,
|
||||||
|
}
|
||||||
|
updated = _update_phase_result(session_id, update_data, user_id, version)
|
||||||
if updated is None:
|
if updated is None:
|
||||||
raise HTTPException(status_code=404, detail="세션을 찾을 수 없습니다")
|
raise HTTPException(status_code=404, detail="세션을 찾을 수 없습니다")
|
||||||
|
|
||||||
|
|
@ -225,29 +342,50 @@ async def run_phase3(session_id: str):
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/translate/{session_id}/phase4")
|
@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."""
|
"""Run Phase 4: polish for readability."""
|
||||||
session = session_store.get(session_id)
|
snapshot = session_store.get_with_version(session_id, user_id)
|
||||||
if session is None:
|
if snapshot is None:
|
||||||
raise HTTPException(status_code=404, detail="세션을 찾을 수 없습니다")
|
raise HTTPException(status_code=404, detail="세션을 찾을 수 없습니다")
|
||||||
|
session, version = snapshot
|
||||||
|
|
||||||
if not session.phase3_result:
|
if not session.phase3_result:
|
||||||
raise HTTPException(status_code=400, detail="먼저 Phase 3을 실행하세요")
|
raise HTTPException(status_code=400, detail="먼저 Phase 3을 실행하세요")
|
||||||
|
|
||||||
alias = session.model_phase4 or session.model_phase3 or session.model_phase1
|
alias = session.model_phase4 or session.model_phase3 or session.model_phase1
|
||||||
|
source_chunks = session.phase3_chunks or _split_text(session.phase3_result)
|
||||||
user_prompt = build_phase4_user_prompt(
|
result_chunks: list[str] = []
|
||||||
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)
|
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}
|
update_data = {
|
||||||
updated = session_store.update(session_id, 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:
|
if updated is None:
|
||||||
raise HTTPException(status_code=404, detail="세션을 찾을 수 없습니다")
|
raise HTTPException(status_code=404, detail="세션을 찾을 수 없습니다")
|
||||||
|
|
||||||
|
|
@ -256,19 +394,276 @@ async def run_phase4(session_id: str):
|
||||||
|
|
||||||
# ── Helpers ───────────────────────────────────────────
|
# ── 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:
|
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()
|
text = text.strip()
|
||||||
# Remove markdown json/code block wrapper
|
|
||||||
if "```" in text:
|
if "```" in text:
|
||||||
parts = text.split("```")
|
parts = text.split("```")
|
||||||
for part in parts:
|
for part in parts:
|
||||||
part = part.strip()
|
part = part.strip()
|
||||||
if part.startswith("json"):
|
if part.lower().startswith("json"):
|
||||||
part = part[4:].strip()
|
part = part[4:].strip()
|
||||||
if part.startswith("{"):
|
if "{" in part and "}" in part:
|
||||||
return part
|
text = part
|
||||||
return text
|
break
|
||||||
|
start = text.find("{")
|
||||||
|
end = text.rfind("}")
|
||||||
|
return text[start : end + 1] if start >= 0 and end > start else text
|
||||||
|
|
||||||
|
|
||||||
# ── Static File Serving (Frontend) ───────────────────
|
# ── Static File Serving (Frontend) ───────────────────
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,20 @@ class Phase1Result(BaseModel):
|
||||||
proper_nouns: list[ProperNoun] # 추출된 고유명사 리스트
|
proper_nouns: list[ProperNoun] # 추출된 고유명사 리스트
|
||||||
summary: str # 내용 요약
|
summary: str # 내용 요약
|
||||||
style: 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):
|
class TranslationSession(BaseModel):
|
||||||
|
|
@ -55,10 +69,13 @@ class TranslationSession(BaseModel):
|
||||||
model_phase3: str = "" # Phase 3 LLM 모델 alias
|
model_phase3: str = "" # Phase 3 LLM 모델 alias
|
||||||
model_phase4: str = "" # Phase 4 LLM 모델 alias
|
model_phase4: str = "" # Phase 4 LLM 모델 alias
|
||||||
phase1_result: Phase1Result | None = None # 초벌 번역 결과
|
phase1_result: Phase1Result | None = None # 초벌 번역 결과
|
||||||
|
phase1_chunks: list[TranslationChunk] = [] # 장문 분할 시 원문/초벌 번역 쌍
|
||||||
phase2_proper_nouns: list[ProperNoun] = [] # Phase 2에서 최종 확정된 고유명사
|
phase2_proper_nouns: list[ProperNoun] = [] # Phase 2에서 최종 확정된 고유명사
|
||||||
phase2_style: str = "" # Phase 2에서 최종 확정된 문체
|
phase2_style: str = "" # Phase 2에서 최종 확정된 문체
|
||||||
phase3_result: str = "" # 재번역 결과
|
phase3_result: str = "" # 재번역 결과
|
||||||
|
phase3_chunks: list[str] = [] # 장문 분할 재번역 결과
|
||||||
phase4_result: str = "" # 마무리 다듬기 결과
|
phase4_result: str = "" # 마무리 다듬기 결과
|
||||||
|
progress: PhaseProgress | None = None # 장기 작업 진행 상태
|
||||||
|
|
||||||
|
|
||||||
# ── API Requests ───────────────────────────────────────
|
# ── API Requests ───────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -5,13 +5,21 @@
|
||||||
|
|
||||||
SYSTEM_PROMPT_PHASE1 = """\
|
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"""\
|
return f"""\
|
||||||
다음 원문을 "{target_language}"으로 번역하세요.
|
{source_instruction} 다음 원문을 "{target_language}"으로 번역하세요.
|
||||||
|
|
||||||
번역 결과를 반드시 아래 JSON 포맷으로만 반환하세요 (마크다운 코드 블록 없이 순수 JSON):
|
번역 결과를 반드시 아래 JSON 포맷으로만 반환하세요 (마크다운 코드 블록 없이 순수 JSON):
|
||||||
|
|
||||||
|
|
@ -44,13 +52,14 @@ SYSTEM_PROMPT_PHASE3 = """\
|
||||||
|
|
||||||
|
|
||||||
def build_phase3_user_prompt(
|
def build_phase3_user_prompt(
|
||||||
|
source_text: str,
|
||||||
phase1_translated: str,
|
phase1_translated: str,
|
||||||
proper_nouns: list[dict],
|
proper_nouns: list[dict],
|
||||||
style: str,
|
style: str,
|
||||||
target_language: str,
|
target_language: str,
|
||||||
) -> str:
|
) -> str:
|
||||||
pn_lines = "\n".join(
|
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")
|
for pn in proper_nouns if pn.get("original") or pn.get("suggested")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -65,10 +74,15 @@ def build_phase3_user_prompt(
|
||||||
재번역 규칙:
|
재번역 규칙:
|
||||||
1. 위 고유명사 매핑을 반드시 준수하세요
|
1. 위 고유명사 매핑을 반드시 준수하세요
|
||||||
2. 지시된 문체에 맞게 어조를 조정하세요
|
2. 지시된 문체에 맞게 어조를 조정하세요
|
||||||
3. 초벌 번역의 의미는 유지하되 더 자연스럽고 정확히 다듬으세요
|
3. 원문을 기준으로 초벌 번역의 누락이나 오역을 바로잡으세요
|
||||||
4. 결과만 반환하세요 (설명 없이 번역문만)
|
4. 결과만 반환하세요 (설명 없이 번역문만)
|
||||||
|
|
||||||
초벌 번역:
|
원문:
|
||||||
|
---
|
||||||
|
{source_text}
|
||||||
|
---
|
||||||
|
|
||||||
|
초벌 번역 참고본:
|
||||||
---
|
---
|
||||||
{phase1_translated}
|
{phase1_translated}
|
||||||
---"""
|
---"""
|
||||||
|
|
@ -78,7 +92,8 @@ def build_phase3_user_prompt(
|
||||||
|
|
||||||
SYSTEM_PROMPT_PHASE4 = """\
|
SYSTEM_PROMPT_PHASE4 = """\
|
||||||
당신은 "{target_language}" 원어민 편집자입니다. 제공된 번역문을 \
|
당신은 "{target_language}" 원어민 편집자입니다. 제공된 번역문을 \
|
||||||
"{target_language}" 화자에게 읽기 편하도록 다듬으세요.\
|
"{target_language}" 화자에게 읽기 편하도록 다듬되, 번역문의 의미 관계와 \
|
||||||
|
인칭, 시제, 양태, 높임 수준은 변경하지 마세요.\
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -86,9 +101,10 @@ def build_phase4_user_prompt(
|
||||||
phase3_result: str,
|
phase3_result: str,
|
||||||
proper_nouns: list[dict],
|
proper_nouns: list[dict],
|
||||||
style: str,
|
style: str,
|
||||||
|
target_language: str,
|
||||||
) -> str:
|
) -> str:
|
||||||
pn_lines = "\n".join(
|
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")
|
for pn in proper_nouns if pn.get("original") or pn.get("suggested")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -102,10 +118,12 @@ def build_phase4_user_prompt(
|
||||||
|
|
||||||
다듬기 규칙:
|
다듬기 규칙:
|
||||||
1. 고유명사는 절대 변경하지 마세요
|
1. 고유명사는 절대 변경하지 마세요
|
||||||
2. 한국어 화자에게 자연스럽게 읽히도록 어순과 표현을 조정하세요
|
2. {target_language} 화자에게 자연스럽게 읽히도록 어순과 표현을 조정하세요
|
||||||
3. 문장은 간결하고 명확하게 다듬으세요
|
3. 문장은 간결하고 명확하게 다듬으세요
|
||||||
4. 의미는 반드시 유지하세요
|
4. 의미는 반드시 유지하세요
|
||||||
5. 결과만 반환하세요 (설명 없이 번역문만)
|
5. 주어와 목적어, 화자와 청자의 관계를 뒤바꾸지 마세요
|
||||||
|
6. 인칭, 시제, 희망·추측 등의 양태와 높임 수준을 변경하지 마세요
|
||||||
|
7. 결과만 반환하세요 (설명 없이 번역문만)
|
||||||
|
|
||||||
번역문:
|
번역문:
|
||||||
---
|
---
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,12 @@ from typing import Any
|
||||||
from backend.models import TranslationSession
|
from backend.models import TranslationSession
|
||||||
|
|
||||||
|
|
||||||
|
class SessionConflictError(Exception):
|
||||||
|
"""Raised when a long-running phase tries to update a changed session."""
|
||||||
|
|
||||||
|
|
||||||
class SessionStore:
|
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.
|
Each session is keyed by a UUID and has a 24-hour TTL.
|
||||||
Expired sessions are lazily cleaned on access.
|
Expired sessions are lazily cleaned on access.
|
||||||
|
|
@ -18,18 +22,26 @@ class SessionStore:
|
||||||
self._store: dict[str, dict[str, Any]] = {}
|
self._store: dict[str, dict[str, Any]] = {}
|
||||||
self.ttl_hours = ttl_hours
|
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]
|
session_id = uuid.uuid4().hex[:16]
|
||||||
self._store[session_id] = {
|
self._store[session_id] = {
|
||||||
"data": session_data.model_dump(),
|
"data": session_data.model_dump(),
|
||||||
|
"owner_id": owner_id,
|
||||||
|
"version": 0,
|
||||||
"created_at": datetime.now(timezone.utc),
|
"created_at": datetime.now(timezone.utc),
|
||||||
"updated_at": datetime.now(timezone.utc),
|
"updated_at": datetime.now(timezone.utc),
|
||||||
}
|
}
|
||||||
return session_id
|
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)
|
entry = self._store.get(session_id)
|
||||||
if entry is None:
|
if entry is None or entry["owner_id"] != owner_id:
|
||||||
return None
|
return None
|
||||||
# Check TTL
|
# Check TTL
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
|
|
@ -39,22 +51,51 @@ class SessionStore:
|
||||||
return None
|
return None
|
||||||
# Touch (refresh TTL)
|
# Touch (refresh TTL)
|
||||||
entry["updated_at"] = now
|
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)
|
entry = self._store.get(session_id)
|
||||||
if entry is None:
|
if entry is None or entry["owner_id"] != owner_id:
|
||||||
return None
|
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["data"].update(data)
|
||||||
entry["updated_at"] = datetime.now(timezone.utc)
|
entry["version"] += 1
|
||||||
|
entry["updated_at"] = now
|
||||||
return TranslationSession(**entry["data"])
|
return TranslationSession(**entry["data"])
|
||||||
|
|
||||||
def delete(self, session_id: str) -> bool:
|
def delete(self, session_id: str, owner_id: str) -> bool:
|
||||||
if session_id in self._store:
|
entry = self._store.get(session_id)
|
||||||
|
if entry is not None and entry["owner_id"] == owner_id:
|
||||||
del self._store[session_id]
|
del self._store[session_id]
|
||||||
return True
|
return True
|
||||||
return False
|
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):
|
def cleanup_expired(self):
|
||||||
"""Remove all expired sessions."""
|
"""Remove all expired sessions."""
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
/** Axios-based API client with JWT token management */
|
/** Axios-based API client with JWT token management */
|
||||||
|
|
||||||
import axios from 'axios'
|
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({
|
const api = axios.create({
|
||||||
baseURL: '/api',
|
baseURL: '/api',
|
||||||
|
|
@ -17,13 +19,13 @@ api.interceptors.request.use((config) => {
|
||||||
return config
|
return config
|
||||||
})
|
})
|
||||||
|
|
||||||
// Auto-redirect on 401
|
// Notify the auth store so App.vue can switch views without a router.
|
||||||
api.interceptors.response.use(
|
api.interceptors.response.use(
|
||||||
(res) => res,
|
(res) => res,
|
||||||
(err) => {
|
(err) => {
|
||||||
if (err.response?.status === 401) {
|
if (err.response?.status === 401) {
|
||||||
localStorage.removeItem('jwt_token')
|
localStorage.removeItem('jwt_token')
|
||||||
window.location.hash = '#/login'
|
window.dispatchEvent(new Event(AUTH_UNAUTHORIZED_EVENT))
|
||||||
}
|
}
|
||||||
return Promise.reject(err)
|
return Promise.reject(err)
|
||||||
},
|
},
|
||||||
|
|
@ -56,15 +58,7 @@ export async function listModels(): Promise<LLMModel[]> {
|
||||||
|
|
||||||
// ── Sessions ─────────────────────────────────────────
|
// ── Sessions ─────────────────────────────────────────
|
||||||
|
|
||||||
export async function createSession(params: {
|
export async function createSession(params: SessionConfig): Promise<{ session_id: string }> {
|
||||||
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 }> {
|
|
||||||
const res = await api.post('/translate/create', params)
|
const res = await api.post('/translate/create', params)
|
||||||
return res.data
|
return res.data
|
||||||
}
|
}
|
||||||
|
|
@ -74,13 +68,20 @@ export async function getSession(sessionId: string): Promise<SessionResponse> {
|
||||||
return res.data
|
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> {
|
export async function deleteSession(sessionId: string): Promise<void> {
|
||||||
await api.delete(`/sessions/${sessionId}`)
|
await api.delete(`/sessions/${sessionId}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateSession(
|
export async function updateSession(
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
patch: Record<string, any>,
|
patch: Partial<SessionConfig>,
|
||||||
): Promise<SessionResponse> {
|
): Promise<SessionResponse> {
|
||||||
const res = await api.patch(`/sessions/${sessionId}`, patch)
|
const res = await api.patch(`/sessions/${sessionId}`, patch)
|
||||||
return res.data
|
return res.data
|
||||||
|
|
@ -95,7 +96,7 @@ export async function runPhase1(sessionId: string): Promise<SessionResponse> {
|
||||||
|
|
||||||
export async function runPhase2(
|
export async function runPhase2(
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
proper_nouns: any[],
|
proper_nouns: ProperNoun[],
|
||||||
style: string,
|
style: string,
|
||||||
): Promise<SessionResponse> {
|
): Promise<SessionResponse> {
|
||||||
const res = await api.post(`/translate/${sessionId}/phase2`, { proper_nouns, style })
|
const res = await api.post(`/translate/${sessionId}/phase2`, { proper_nouns, style })
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref, computed } from 'vue'
|
import { computed, onScopeDispose, ref } from 'vue'
|
||||||
import { me as apiMe, logout as apiLogout } from '../api'
|
import { AUTH_UNAUTHORIZED_EVENT, me as apiMe, logout as apiLogout } from '../api'
|
||||||
|
|
||||||
export const useAuthStore = defineStore('auth', () => {
|
export const useAuthStore = defineStore('auth', () => {
|
||||||
const user_id = ref<string | null>(null)
|
const user_id = ref<string | null>(null)
|
||||||
|
|
@ -8,6 +8,14 @@ export const useAuthStore = defineStore('auth', () => {
|
||||||
|
|
||||||
const isLoggedIn = computed(() => !!user_id.value && loaded.value)
|
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() {
|
async function check() {
|
||||||
if (loaded.value) return
|
if (loaded.value) return
|
||||||
try {
|
try {
|
||||||
|
|
@ -23,7 +31,6 @@ export const useAuthStore = defineStore('auth', () => {
|
||||||
async function logout() {
|
async function logout() {
|
||||||
await apiLogout()
|
await apiLogout()
|
||||||
user_id.value = null
|
user_id.value = null
|
||||||
window.location.hash = '#/login'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return { user_id, loaded, isLoggedIn, check, logout }
|
return { user_id, loaded, isLoggedIn, check, logout }
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,32 @@
|
||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref, computed } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import type { ProperNoun, TranslationSessionData, HistoryEntry, LLMModel } from '../types'
|
import { isAxiosError } from 'axios'
|
||||||
|
import type { HistoryEntry, LLMModel, Phase1Result, PhaseProgress, ProperNoun, SessionConfig, TranslationSessionData } from '../types'
|
||||||
import * as api from '../api'
|
import * as api from '../api'
|
||||||
|
import { useAuthStore } from './auth'
|
||||||
|
|
||||||
const HISTORY_KEY = 'llm_translator_history'
|
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', () => {
|
export const useTranslationStore = defineStore('translation', () => {
|
||||||
|
const auth = useAuthStore()
|
||||||
// ── State ────────────────────────────────────────
|
// ── State ────────────────────────────────────────
|
||||||
const sessionId = ref<string | null>(null)
|
const sessionId = ref<string | null>(null)
|
||||||
const sourceText = ref('')
|
const sourceText = ref('')
|
||||||
const sourceLanguage = ref('auto')
|
const sourceLanguage = ref('auto')
|
||||||
const targetLanguage = ref('한국어')
|
const targetLanguage = ref('한국어')
|
||||||
const modelPhase1 = ref('')
|
const modelPhase1 = ref('')
|
||||||
const modelPhase2 = ref('')
|
|
||||||
const modelPhase3 = ref('')
|
const modelPhase3 = ref('')
|
||||||
const modelPhase4 = ref('')
|
const modelPhase4 = ref('')
|
||||||
|
|
||||||
// Phase results
|
// 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 phase2ProperNouns = ref<ProperNoun[]>([])
|
||||||
const phase2Style = ref('')
|
const phase2Style = ref('')
|
||||||
const phase3Result = ref('')
|
const phase3Result = ref('')
|
||||||
|
|
@ -30,22 +38,51 @@ export const useTranslationStore = defineStore('translation', () => {
|
||||||
const error = ref<string | null>(null)
|
const error = ref<string | null>(null)
|
||||||
const models = ref<LLMModel[]>([])
|
const models = ref<LLMModel[]>([])
|
||||||
const history = ref<HistoryEntry[]>([])
|
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 ─────────────────────────────────────
|
// ── Computed ─────────────────────────────────────
|
||||||
const phase1Done = computed(() => !!phase1Result.value)
|
const phase1Done = computed(() => !!phase1Result.value)
|
||||||
const phase2Ready = computed(() => phase1Done.value) // can edit after P1
|
const phase2Ready = computed(() => phase1Done.value && !historySnapshot.value)
|
||||||
const phase3Ready = computed(() => phase1Done.value && (phase2ProperNouns.value.length > 0 || phase2Style.value))
|
const phase3Ready = computed(() => phase1Done.value && !historySnapshot.value)
|
||||||
const phase4Ready = computed(() => !!phase3Result.value)
|
const phase4Ready = computed(() => !!phase3Result.value && !historySnapshot.value)
|
||||||
|
|
||||||
const activeAbortController = ref<AbortController | null>(null)
|
|
||||||
|
|
||||||
// ── Actions ──────────────────────────────────────
|
// ── 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() {
|
async function loadModels() {
|
||||||
try {
|
try {
|
||||||
models.value = await api.listModels()
|
models.value = await api.listModels()
|
||||||
} catch (e: any) {
|
} catch (error: unknown) {
|
||||||
console.error('Failed to load models:', e)
|
console.error('Failed to load models:', error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -60,23 +97,46 @@ export const useTranslationStore = defineStore('translation', () => {
|
||||||
phase3Result.value = ''
|
phase3Result.value = ''
|
||||||
phase4Result.value = ''
|
phase4Result.value = ''
|
||||||
error.value = null
|
error.value = null
|
||||||
|
historySnapshot.value = false
|
||||||
|
|
||||||
// Restore model selections from localStorage
|
const savedModels = loadSavedModels()
|
||||||
const savedModels = JSON.parse(localStorage.getItem('llm_translator_models') || '{}')
|
const defaultModel = models.value[0]?.alias || ''
|
||||||
modelPhase1.value = savedModels.p1 || models.value[0]?.alias || ''
|
modelPhase1.value = validModel(savedModels.p1, defaultModel)
|
||||||
modelPhase2.value = savedModels.p2 || models.value[0]?.alias || ''
|
modelPhase3.value = validModel(savedModels.p3, defaultModel)
|
||||||
modelPhase3.value = savedModels.p3 || models.value[0]?.alias || ''
|
modelPhase4.value = validModel(savedModels.p4, models.value[1]?.alias || defaultModel)
|
||||||
modelPhase4.value = savedModels.p4 || (models.value[1]?.alias || models.value[0]?.alias || '')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveModelSelections() {
|
function saveModelSelections() {
|
||||||
localStorage.setItem('llm_translator_models', JSON.stringify({
|
try {
|
||||||
|
localStorage.setItem(MODELS_KEY, JSON.stringify({
|
||||||
p1: modelPhase1.value,
|
p1: modelPhase1.value,
|
||||||
p2: modelPhase2.value,
|
|
||||||
p3: modelPhase3.value,
|
p3: modelPhase3.value,
|
||||||
p4: modelPhase4.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() {
|
async function ensureSession() {
|
||||||
if (!sessionId.value) {
|
if (!sessionId.value) {
|
||||||
|
|
@ -85,13 +145,13 @@ export const useTranslationStore = defineStore('translation', () => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSessionParams() {
|
function getSessionParams(): SessionConfig {
|
||||||
return {
|
return {
|
||||||
source_text: sourceText.value,
|
source_text: sourceText.value,
|
||||||
source_language: sourceLanguage.value,
|
source_language: sourceLanguage.value,
|
||||||
target_language: targetLanguage.value,
|
target_language: targetLanguage.value,
|
||||||
model_phase1: modelPhase1.value,
|
model_phase1: modelPhase1.value,
|
||||||
model_phase2: modelPhase2.value,
|
model_phase2: '',
|
||||||
model_phase3: modelPhase3.value,
|
model_phase3: modelPhase3.value,
|
||||||
model_phase4: modelPhase4.value,
|
model_phase4: modelPhase4.value,
|
||||||
}
|
}
|
||||||
|
|
@ -99,10 +159,44 @@ export const useTranslationStore = defineStore('translation', () => {
|
||||||
|
|
||||||
async function syncToServer() {
|
async function syncToServer() {
|
||||||
if (!sessionId.value) return
|
if (!sessionId.value) return
|
||||||
try {
|
|
||||||
await api.updateSession(sessionId.value, getSessionParams())
|
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
|
loading.value = true
|
||||||
currentPhaseLoading.value = 1
|
currentPhaseLoading.value = 1
|
||||||
error.value = null
|
error.value = null
|
||||||
activeAbortController.value = new AbortController()
|
phase3Result.value = ''
|
||||||
|
phase4Result.value = ''
|
||||||
try {
|
try {
|
||||||
await ensureSession()
|
await ensureSession()
|
||||||
if (!sessionId.value) throw new Error('세션 생성 실패')
|
if (!sessionId.value) throw new Error('세션 생성 실패')
|
||||||
|
await syncToServer()
|
||||||
|
startProgressPolling()
|
||||||
const res = await api.runPhase1(sessionId.value)
|
const res = await api.runPhase1(sessionId.value)
|
||||||
phase1Result.value = res.data.phase1_result
|
phase1Result.value = res.data.phase1_result
|
||||||
phase2ProperNouns.value = res.data.phase2_proper_nouns || []
|
phase2ProperNouns.value = res.data.phase2_proper_nouns || []
|
||||||
phase2Style.value = res.data.phase2_style || ''
|
phase2Style.value = res.data.phase2_style || ''
|
||||||
} catch (e: any) {
|
historySnapshot.value = false
|
||||||
error.value = e.response?.data?.detail || e.message || 'Phase 1 실행 실패'
|
saveToHistory()
|
||||||
|
} catch (caughtError: unknown) {
|
||||||
|
error.value = getErrorMessage(caughtError, 'Phase 1 실행 실패')
|
||||||
} finally {
|
} finally {
|
||||||
|
stopProgressPolling()
|
||||||
loading.value = false
|
loading.value = false
|
||||||
currentPhaseLoading.value = null
|
currentPhaseLoading.value = null
|
||||||
activeAbortController.value = null
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -146,18 +244,14 @@ export const useTranslationStore = defineStore('translation', () => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
currentPhaseLoading.value = 2
|
currentPhaseLoading.value = 2
|
||||||
error.value = null
|
error.value = null
|
||||||
|
phase3Result.value = ''
|
||||||
|
phase4Result.value = ''
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await api.runPhase2(
|
await submitPhase2()
|
||||||
sessionId.value,
|
saveToHistory()
|
||||||
phase2ProperNouns.value.map(pn => ({ ...pn, final: pn.final || pn.suggested })),
|
} catch (caughtError: unknown) {
|
||||||
phase2Style.value,
|
error.value = getErrorMessage(caughtError, 'Phase 2 확인 실패')
|
||||||
)
|
|
||||||
// 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 확인 실패'
|
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
currentPhaseLoading.value = null
|
currentPhaseLoading.value = null
|
||||||
|
|
@ -177,19 +271,25 @@ export const useTranslationStore = defineStore('translation', () => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
currentPhaseLoading.value = 3
|
currentPhaseLoading.value = 3
|
||||||
error.value = null
|
error.value = null
|
||||||
activeAbortController.value = new AbortController()
|
phase3Result.value = ''
|
||||||
|
phase4Result.value = ''
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await ensureSession()
|
await ensureSession()
|
||||||
if (!sessionId.value) throw new Error('세션 생성 실패')
|
if (!sessionId.value) throw new Error('세션 생성 실패')
|
||||||
|
await syncToServer()
|
||||||
|
await submitPhase2()
|
||||||
|
saveToHistory()
|
||||||
|
startProgressPolling()
|
||||||
const res = await api.runPhase3(sessionId.value)
|
const res = await api.runPhase3(sessionId.value)
|
||||||
phase3Result.value = res.data.phase3_result || ''
|
phase3Result.value = res.data.phase3_result || ''
|
||||||
} catch (e: any) {
|
saveToHistory()
|
||||||
error.value = e.response?.data?.detail || e.message || 'Phase 3 실행 실패'
|
} catch (caughtError: unknown) {
|
||||||
|
error.value = getErrorMessage(caughtError, 'Phase 3 실행 실패')
|
||||||
} finally {
|
} finally {
|
||||||
|
stopProgressPolling()
|
||||||
loading.value = false
|
loading.value = false
|
||||||
currentPhaseLoading.value = null
|
currentPhaseLoading.value = null
|
||||||
activeAbortController.value = null
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -202,19 +302,21 @@ export const useTranslationStore = defineStore('translation', () => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
currentPhaseLoading.value = 4
|
currentPhaseLoading.value = 4
|
||||||
error.value = null
|
error.value = null
|
||||||
activeAbortController.value = new AbortController()
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await ensureSession()
|
await ensureSession()
|
||||||
if (!sessionId.value) throw new Error('세션 생성 실패')
|
if (!sessionId.value) throw new Error('세션 생성 실패')
|
||||||
|
await syncToServer()
|
||||||
|
startProgressPolling()
|
||||||
const res = await api.runPhase4(sessionId.value)
|
const res = await api.runPhase4(sessionId.value)
|
||||||
phase4Result.value = res.data.phase4_result || ''
|
phase4Result.value = res.data.phase4_result || ''
|
||||||
} catch (e: any) {
|
saveToHistory()
|
||||||
error.value = e.response?.data?.detail || e.message || 'Phase 4 실행 실패'
|
} catch (caughtError: unknown) {
|
||||||
|
error.value = getErrorMessage(caughtError, 'Phase 4 실행 실패')
|
||||||
} finally {
|
} finally {
|
||||||
|
stopProgressPolling()
|
||||||
loading.value = false
|
loading.value = false
|
||||||
currentPhaseLoading.value = null
|
currentPhaseLoading.value = null
|
||||||
activeAbortController.value = null
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -239,32 +341,43 @@ export const useTranslationStore = defineStore('translation', () => {
|
||||||
// Keep max 50 entries
|
// Keep max 50 entries
|
||||||
if (history.value.length > 50) history.value.pop()
|
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() {
|
function loadHistory() {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(HISTORY_KEY) || '[]'
|
const key = historyStorageKey()
|
||||||
|
const raw = localStorage.getItem(key) || '[]'
|
||||||
history.value = JSON.parse(raw) as HistoryEntry[]
|
history.value = JSON.parse(raw) as HistoryEntry[]
|
||||||
|
localStorage.removeItem(HISTORY_KEY)
|
||||||
} catch {
|
} catch {
|
||||||
history.value = []
|
history.value = []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function restoreFromHistory(entry: HistoryEntry) {
|
function restoreFromHistory(entry: HistoryEntry) {
|
||||||
sessionId.value = entry.id
|
sessionId.value = null
|
||||||
sourceText.value = entry.data.source_text
|
sourceText.value = entry.data.source_text
|
||||||
sourceLanguage.value = entry.data.source_language
|
sourceLanguage.value = entry.data.source_language
|
||||||
targetLanguage.value = entry.data.target_language
|
targetLanguage.value = entry.data.target_language
|
||||||
modelPhase1.value = entry.data.model_phase1
|
const defaultModel = models.value[0]?.alias || ''
|
||||||
modelPhase2.value = entry.data.model_phase2
|
modelPhase1.value = validModel(entry.data.model_phase1, defaultModel)
|
||||||
modelPhase3.value = entry.data.model_phase3
|
modelPhase3.value = validModel(entry.data.model_phase3, defaultModel)
|
||||||
modelPhase4.value = entry.data.model_phase4
|
modelPhase4.value = validModel(entry.data.model_phase4, models.value[1]?.alias || defaultModel)
|
||||||
phase1Result.value = entry.data.phase1_result
|
phase1Result.value = entry.data.phase1_result
|
||||||
phase2ProperNouns.value = entry.data.phase2_proper_nouns || []
|
phase2ProperNouns.value = entry.data.phase2_proper_nouns || []
|
||||||
phase2Style.value = entry.data.phase2_style || ''
|
phase2Style.value = entry.data.phase2_style || ''
|
||||||
phase3Result.value = entry.data.phase3_result || ''
|
phase3Result.value = entry.data.phase3_result || ''
|
||||||
phase4Result.value = entry.data.phase4_result || ''
|
phase4Result.value = entry.data.phase4_result || ''
|
||||||
|
historySnapshot.value = true
|
||||||
|
|
||||||
// Save model selections for future sessions
|
// Save model selections for future sessions
|
||||||
saveModelSelections()
|
saveModelSelections()
|
||||||
|
|
@ -276,7 +389,7 @@ export const useTranslationStore = defineStore('translation', () => {
|
||||||
source_language: sourceLanguage.value,
|
source_language: sourceLanguage.value,
|
||||||
target_language: targetLanguage.value,
|
target_language: targetLanguage.value,
|
||||||
model_phase1: modelPhase1.value,
|
model_phase1: modelPhase1.value,
|
||||||
model_phase2: modelPhase2.value,
|
model_phase2: '',
|
||||||
model_phase3: modelPhase3.value,
|
model_phase3: modelPhase3.value,
|
||||||
model_phase4: modelPhase4.value,
|
model_phase4: modelPhase4.value,
|
||||||
phase1_result: phase1Result.value || null,
|
phase1_result: phase1Result.value || null,
|
||||||
|
|
@ -309,7 +422,7 @@ export const useTranslationStore = defineStore('translation', () => {
|
||||||
// Add simple markdown formatting — frontmatter with metadata
|
// Add simple markdown formatting — frontmatter with metadata
|
||||||
const md = `---
|
const md = `---
|
||||||
source_language: ${sourceLanguage.value}
|
source_language: ${sourceLanguage.value}
|
||||||
target_language: ${target_language.value}
|
target_language: ${targetLanguage.value}
|
||||||
timestamp: ${new Date().toISOString()}
|
timestamp: ${new Date().toISOString()}
|
||||||
---\n\n${content}`
|
---\n\n${content}`
|
||||||
downloadTxt(filename.replace('.txt', '.md'), md)
|
downloadTxt(filename.replace('.txt', '.md'), md)
|
||||||
|
|
@ -318,10 +431,10 @@ timestamp: ${new Date().toISOString()}
|
||||||
return {
|
return {
|
||||||
// State
|
// State
|
||||||
sessionId, sourceText, sourceLanguage, targetLanguage,
|
sessionId, sourceText, sourceLanguage, targetLanguage,
|
||||||
modelPhase1, modelPhase2, modelPhase3, modelPhase4,
|
modelPhase1, modelPhase3, modelPhase4,
|
||||||
phase1Result, phase2ProperNouns, phase2Style,
|
phase1Result, phase2ProperNouns, phase2Style,
|
||||||
phase3Result, phase4Result,
|
phase3Result, phase4Result,
|
||||||
loading, currentPhaseLoading, error, models, history,
|
loading, currentPhaseLoading, error, models, history, progress,
|
||||||
|
|
||||||
// Computed
|
// Computed
|
||||||
phase1Done, phase2Ready, phase3Ready, phase4Ready,
|
phase1Done, phase2Ready, phase3Ready, phase4Ready,
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,15 @@ export interface Phase1Result {
|
||||||
proper_nouns: ProperNoun[]
|
proper_nouns: ProperNoun[]
|
||||||
summary: string
|
summary: string
|
||||||
style: string
|
style: string
|
||||||
|
warnings?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PhaseProgress {
|
||||||
|
phase: number
|
||||||
|
chunk: number
|
||||||
|
total_chunks: number
|
||||||
|
status: string
|
||||||
|
preview: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TranslationSessionData {
|
export interface TranslationSessionData {
|
||||||
|
|
@ -28,12 +37,26 @@ export interface TranslationSessionData {
|
||||||
model_phase3: string
|
model_phase3: string
|
||||||
model_phase4: string
|
model_phase4: string
|
||||||
phase1_result: Phase1Result | null
|
phase1_result: Phase1Result | null
|
||||||
|
phase1_chunks?: Array<{ source_text: string; translated: string }>
|
||||||
phase2_proper_nouns: ProperNoun[]
|
phase2_proper_nouns: ProperNoun[]
|
||||||
phase2_style: string
|
phase2_style: string
|
||||||
phase3_result: string
|
phase3_result: string
|
||||||
|
phase3_chunks?: string[]
|
||||||
phase4_result: 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 {
|
export interface SessionResponse {
|
||||||
session_id: string
|
session_id: string
|
||||||
data: TranslationSessionData
|
data: TranslationSessionData
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
|
import { isAxiosError } from 'axios'
|
||||||
import * as api from '../api'
|
import * as api from '../api'
|
||||||
|
|
||||||
const id = ref('')
|
const id = ref('')
|
||||||
|
|
@ -14,8 +15,10 @@ async function handleLogin() {
|
||||||
try {
|
try {
|
||||||
await api.login(id.value, password.value)
|
await api.login(id.value, password.value)
|
||||||
window.location.reload()
|
window.location.reload()
|
||||||
} catch (e: any) {
|
} catch (error: unknown) {
|
||||||
errorMessage.value = e.response?.data?.detail || '로그인에 실패했습니다'
|
errorMessage.value = isAxiosError<{ detail?: string }>(error)
|
||||||
|
? error.response?.data?.detail || '로그인에 실패했습니다'
|
||||||
|
: '로그인에 실패했습니다'
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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">
|
<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">
|
<div class="flex flex-wrap items-end gap-4">
|
||||||
<LLMSelector label="Phase 1 — 초벌번역" v-model:value="store.modelPhase1" :models="store.models" />
|
<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 3 — 재번역" v-model:value="store.modelPhase3" :models="store.models" />
|
||||||
<LLMSelector label="Phase 4 — 마무리" v-model:value="store.modelPhase4" :models="store.models" />
|
<LLMSelector label="Phase 4 — 마무리" v-model:value="store.modelPhase4" :models="store.models" />
|
||||||
|
|
||||||
|
|
@ -208,16 +207,32 @@ function handleNewTranslation() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Loading Indicator -->
|
<!-- 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">
|
<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>
|
<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>
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||||
</svg>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Phase 2: Proper Noun Editor -->
|
<!-- Phase 2: Proper Noun Editor -->
|
||||||
<section v-if="store.phase1Done">
|
<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
|
<ProperNounsEditor
|
||||||
:proper_nouns="store.phase2ProperNouns"
|
:proper_nouns="store.phase2ProperNouns"
|
||||||
:style="store.phase2Style"
|
:style="store.phase2Style"
|
||||||
|
|
|
||||||
2
requirements-dev.txt
Normal file
2
requirements-dev.txt
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
-r requirements.txt
|
||||||
|
httpx==0.28.*
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
fastapi==0.115.*
|
fastapi==0.115.*
|
||||||
uvicorn[standard]==0.34.*
|
uvicorn[standard]==0.34.*
|
||||||
pyjwt==2.10.*
|
pyjwt==2.10.*
|
||||||
passlib[bcrypt]==1.7.*
|
bcrypt>=4.2,<6
|
||||||
openai==1.66.*
|
openai==1.66.*
|
||||||
python-dotenv==1.0.*
|
python-dotenv==1.0.*
|
||||||
|
|
|
||||||
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
252
tests/test_backend.py
Normal file
252
tests/test_backend.py
Normal 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()
|
||||||
Loading…
Add table
Add a link
Reference in a new issue