Add persistent multi-user sessions and Docker deployment

This commit is contained in:
burnintuna 2026-07-31 02:39:00 +09:00
parent 51a6e845d1
commit 104577c826
24 changed files with 2094 additions and 468 deletions

23
.dockerignore Normal file
View file

@ -0,0 +1,23 @@
.git
.gitignore
.env
.env.*
!.env.example
.venv
venv
__pycache__
*.py[cod]
*.db
*.db-shm
*.db-wal
data
config/*.json
frontend/node_modules
frontend/dist
tests
.pytest_cache
.mypy_cache
.ruff_cache
*.md
compose*.yml
Dockerfile*

View file

@ -1,8 +1,15 @@
# 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_KEY 미설정 시 생성할 키 파일 (기본: data/.jwt-secret)
# JWT_SECRET_FILE=config/.jwt-secret # JWT_SECRET_FILE=data/.jwt-secret
# 영속 SQLite 데이터베이스
DATABASE_PATH=data/translator.db
# users.json이 없고 DB가 비어 있을 때만 사용할 최초 관리자
# BOOTSTRAP_ADMIN_ID=admin
# BOOTSTRAP_ADMIN_PASSWORD=change-this-password
# LLM 설정 파일 경로 (기본: config/llms.json) # LLM 설정 파일 경로 (기본: config/llms.json)
LLMS_CONFIG_PATH=config/llms.json LLMS_CONFIG_PATH=config/llms.json

6
.gitignore vendored
View file

@ -10,6 +10,12 @@ __pycache__/
# Build output # Build output
frontend/dist/ frontend/dist/
# Persistent application data
data/
*.db
*.db-shm
*.db-wal
# Env files # Env files
.env .env
config/.jwt-secret config/.jwt-secret

View file

@ -32,7 +32,8 @@ LLM-translator/
│ ├── llm_client.py # OpenAI-compatible client (async) │ ├── llm_client.py # OpenAI-compatible client (async)
│ ├── models.py # Pydantic schemas for all API I/O │ ├── models.py # Pydantic schemas for all API I/O
│ ├── prompts.py # Phase 14 prompt templates │ ├── prompts.py # Phase 14 prompt templates
│ └── sessions.py # In-memory session store (TTL 24h, UUID keys) │ ├── database.py # SQLite schema, users, sessions, revision persistence
│ └── sessions.py # Persistent session facade + transient progress
├── frontend/ # Vue 3 SPA source ├── frontend/ # Vue 3 SPA source
│ ├── src/ │ ├── src/
│ │ ├── main.ts # App entry + dark mode pre-mount class application │ │ ├── main.ts # App entry + dark mode pre-mount class application
@ -46,7 +47,7 @@ LLM-translator/
│ │ │ └── ComparisonView.vue # 3-column Phase 1/3/4 result comparison │ │ │ └── ComparisonView.vue # 3-column Phase 1/3/4 result comparison
│ │ ├── stores/ │ │ ├── stores/
│ │ │ ├── auth.ts # JWT state (check/login/logout) │ │ │ ├── auth.ts # JWT state (check/login/logout)
│ │ │ ├── translation.ts # Pipeline state + localStorage persistence │ │ │ ├── translation.ts # Pipeline state + server session persistence
│ │ │ └── theme.ts # Dark mode toggle + localStorage + system preference detection │ │ │ └── theme.ts # Dark mode toggle + localStorage + system preference detection
│ │ ├── api.ts # Axios client with JWT interceptor │ │ ├── api.ts # Axios client with JWT interceptor
│ │ └── types.ts # TypeScript interfaces mirroring backend models │ │ └── types.ts # TypeScript interfaces mirroring backend models
@ -68,7 +69,9 @@ LLM-translator/
- **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` caches an `AsyncOpenAI` client by alias. 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** — 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. - **Session store** — SQLite-backed, owner-scoped sessions with no automatic TTL deletion. Completed and draft sessions persist across restarts. Streaming progress remains process-local and transient.
- **Session revisions** — the latest previous translation snapshot is saved before an edit or phase rerun overwrites results, allowing one-step restore.
- **Users** — users are stored in SQLite and created by administrators. `users.json` is imported once for compatibility; password changes and deactivation invalidate existing JWTs through `token_version`.
- **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. - **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. - **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.
@ -77,7 +80,8 @@ LLM-translator/
### Frontend ### Frontend
- **All Vue components use `<script setup lang="ts">`** with Composition API. No Options API. - **All Vue components use `<script setup lang="ts">`** with Composition API. No Options API.
- **State management via Pinia stores**`auth`, `translation`, `theme`. Stores persist model selections and history to `localStorage`. - **State management via Pinia stores**`auth`, `translation`, `theme`. Only model preferences, JWT, and theme are persisted to `localStorage`.
- **Server history** — translation history is canonical in SQLite. The frontend keeps only JWT, theme, and model preferences in `localStorage`, and debounces draft changes to the active server session.
- **Dark mode is class-based** (`<html class="dark">`). Applied in `main.ts` before mount to prevent flash, then toggled by `useThemeStore.toggle()`. Every UI component has corresponding `dark:` Tailwind classes. - **Dark mode is class-based** (`<html class="dark">`). Applied in `main.ts` before mount to prevent flash, then toggled by `useThemeStore.toggle()`. Every UI component has corresponding `dark:` Tailwind classes.
- **API calls go through `/api` proxy** during dev (vite.config.ts). In production the same origin serves both API and static files. - **API calls go through `/api` proxy** during dev (vite.config.ts). In production the same origin serves both API and static files.
@ -127,8 +131,8 @@ 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 && npm run build && cd .. cd frontend && npm run build && cd ..
# 3. Configure LLM servers and users # 3. Configure LLM servers and initial users
# Edit config/llms.json and config/users.json directly # users.json is imported once; later users are managed in the admin UI
# 4. Start server # 4. Start server
uvicorn backend.main:app --host 0.0.0.0 --port 8000 uvicorn backend.main:app --host 0.0.0.0 --port 8000
@ -160,7 +164,7 @@ JWT_SECRET_KEY=your-secret uvicorn backend.main:app --reload
## File I/O Notes ## File I/O Notes
- **config/llms.json** — loaded once at startup by `llm_client.py._load_llm_configs()`. Not re-read on requests. Changes require server restart. - **config/llms.json** — loaded once at startup by `llm_client.py._load_llm_configs()`. Not re-read on requests. Changes require server restart.
- **config/users.json**loaded once at startup by `auth.py._load_users_file()`. Plain-text passwords are auto-migrated to bcrypt hashes and saved back on first run only. - **config/users.json**imported transactionally once into SQLite. Plain-text passwords are accepted only when the file is writable so they can be replaced with bcrypt hashes; Docker deployments should use pre-hashed entries or bootstrap environment variables.
## Testing Against LLM Server ## Testing Against LLM Server

95
DEPLOYMENT.md Normal file
View file

@ -0,0 +1,95 @@
# Docker Deployment
The production image builds the Vue frontend with Node 22, installs the Python runtime dependencies into the official Python 3.14 slim image, and runs Uvicorn as UID/GID `10001` with one worker. Application data is stored in the `translator-data` named volume at `/app/data`.
## Prerequisites
- Docker Engine with the Compose plugin
- `config/llms.json` containing the production LLM endpoints and credentials
- Bootstrap administrator environment variables for a new Docker volume
The configuration files are not copied into the image or build context. Compose mounts them read-only. Docker uses the empty `config/users.docker.json`, so the repository's documented local development account is never imported into a production volume.
## Environment
Create a deployment `.env` beside `compose.yml`. Compose reads this file for variable substitution; it is excluded from Git and the Docker build context.
Minimal example using the persistent JWT secret generated in `/app/data`:
```dotenv
APP_PORT=8000
JWT_EXPIRE_HOURS=24
LLM_TIMEOUT_SECONDS=180
TRANSLATION_CHUNK_CHARS=1500
```
Before the first startup of a new volume, add deployment-specific bootstrap credentials:
```dotenv
BOOTSTRAP_ADMIN_ID=admin
BOOTSTRAP_ADMIN_PASSWORD=replace-with-a-long-random-password
```
The bootstrap variables are only used when the database contains no users. Remove them from `.env` after the first successful startup and recreate the container with `docker compose up -d --force-recreate`.
By default, the application creates `/app/data/.jwt-secret` with mode `0600`. Because the data directory is a named volume, tokens remain valid across container replacement. To manage the secret externally instead, set a stable random value:
```dotenv
JWT_SECRET_KEY=replace-with-at-least-48-random-bytes
```
Do not rotate `JWT_SECRET_KEY` unintentionally; rotation invalidates existing login tokens. Generate values with a secrets manager or a command such as `openssl rand -base64 48`, and do not commit the resulting `.env`.
## Configuration
`config/llms.json` is required and readiness fails when it contains no model definitions. It may contain API keys, so limit host access while ensuring UID `10001` in the container can read the bind-mounted file. Compose imports the empty `config/users.docker.json` once and creates the first administrator from bootstrap variables. Later user administration is stored in SQLite. A custom legacy file containing `password_plain` must be migrated once outside the read-only container so the plaintext can be removed.
The container receives these fixed paths:
```text
DATABASE_PATH=/app/data/translator.db
JWT_SECRET_FILE=/app/data/.jwt-secret
LLMS_CONFIG_PATH=/app/config/llms.json
USERS_CONFIG_PATH=/app/config/users.json
```
## Start And Verify
Build and start the service:
```bash
docker compose up -d --build
docker compose ps
docker compose logs -f translator
```
The service is available at `http://localhost:${APP_PORT:-8000}`. Docker checks `GET /api/health/ready` every 30 seconds. This readiness check validates SQLite access and that at least one LLM configuration loaded; it does not contact the remote LLM endpoint.
To test readiness directly:
```bash
curl --fail http://localhost:8000/api/health/ready
```
## Operations
Deploy an updated image with:
```bash
docker compose build --pull
docker compose up -d
```
Stop without deleting persistent data:
```bash
docker compose down
```
Do not add `--volumes` unless the database and generated JWT secret should be permanently removed. Compose allows 190 seconds for shutdown, while Uvicorn allows in-flight requests up to 180 seconds before forcing shutdown.
Back up the named volume before upgrades. For a consistent simple backup, stop the service first and copy the volume contents with your standard Docker volume backup tooling. The volume contains the SQLite database, WAL files when present, and the generated JWT secret.
## Reverse Proxy
Terminate TLS in a reverse proxy and forward traffic to port `8000`. Preserve the same origin for the frontend and `/api` routes. Do not expose the LLM configuration files, data volume, or Docker socket through the proxy.

39
Dockerfile Normal file
View file

@ -0,0 +1,39 @@
# syntax=docker/dockerfile:1
FROM node:22-alpine AS frontend-build
WORKDIR /build/frontend
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci
COPY frontend/ ./
RUN npm run build
FROM python:3.14-slim AS runtime
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1
WORKDIR /app
RUN groupadd --gid 10001 app \
&& useradd --uid 10001 --gid app --create-home --home-dir /home/app app \
&& mkdir -p /app/config /app/data /app/frontend \
&& chown -R app:app /app /home/app
COPY requirements.txt ./
RUN python -m pip install --no-cache-dir --requirement requirements.txt
COPY --chown=app:app backend/ ./backend/
COPY --from=frontend-build --chown=app:app /build/frontend/dist/ ./frontend/dist/
USER app
EXPOSE 8000
VOLUME ["/app/data"]
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/api/health/ready', timeout=4).close()"]
CMD ["python", "-m", "uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1", "--timeout-graceful-shutdown", "180"]

View file

@ -10,6 +10,9 @@
- **세로 3열 비교 UI**: Phase 1 / Phase 3 / Phase 4 결과를 한눈에 비교 - **세로 3열 비교 UI**: Phase 1 / Phase 3 / Phase 4 결과를 한눈에 비교
- **다크모드 지원**: 시스템 설정 자동 감지 + 수동 토글 (localStorage 지속화) - **다크모드 지원**: 시스템 설정 자동 감지 + 수동 토글 (localStorage 지속화)
- **JWT 기반 인증**: config-file 기반 ID/PW 로그인 - **JWT 기반 인증**: config-file 기반 ID/PW 로그인
- **다중 사용자 및 영속 세션**: 사용자별 SQLite 세션 저장과 동일 세션 재개
- **직전 버전 복원**: 번역을 덮어쓰기 전 세션 snapshot 보존
- **관리자 사용자 관리**: 계정 생성, 비밀번호 초기화, 활성화 및 권한 관리
- **단일 서버 배포**: FastAPI가 API + 정적 프론트엔드 동시에 서빙 - **단일 서버 배포**: FastAPI가 API + 정적 프론트엔드 동시에 서빙
## 빠른 시작 ## 빠른 시작
@ -64,6 +67,9 @@ uvicorn backend.main:app --host 0.0.0.0 --port 8000
| `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` |
| `DATABASE_PATH` | SQLite DB 경로 | `data/translator.db` |
| `BOOTSTRAP_ADMIN_ID` | 최초 관리자 ID | 없음 |
| `BOOTSTRAP_ADMIN_PASSWORD` | 최초 관리자 비밀번호 | 없음 |
| `LLM_TIMEOUT_SECONDS` | 개별 LLM 호출 제한 시간 | `180` | | `LLM_TIMEOUT_SECONDS` | 개별 LLM 호출 제한 시간 | `180` |
| `TRANSLATION_CHUNK_CHARS` | 장문 분할 목표 크기 | `1500` | | `TRANSLATION_CHUNK_CHARS` | 장문 분할 목표 크기 | `1500` |
@ -120,6 +126,15 @@ uvicorn backend.main:app --host 0.0.0.0 --port 8000
LLM 처리 중에는 현재 chunk 번호와 스트리밍으로 수신 중인 최근 내용이 화면에 임시 표시됩니다. Phase 1 JSON이 불완전하면 코드 블록·주변 문구·후행 쉼표를 먼저 보정하고, 실패 시 LLM JSON 복구를 거쳐 일반 번역 결과로 폴백합니다. LLM 처리 중에는 현재 chunk 번호와 스트리밍으로 수신 중인 최근 내용이 화면에 임시 표시됩니다. Phase 1 JSON이 불완전하면 코드 블록·주변 문구·후행 쉼표를 먼저 보정하고, 실패 시 LLM JSON 복구를 거쳐 일반 번역 결과로 폴백합니다.
## Docker 배포
```bash
docker compose up -d --build
docker compose ps
```
첫 실행 전에 `.env``BOOTSTRAP_ADMIN_ID`와 충분히 강한 `BOOTSTRAP_ADMIN_PASSWORD`를 지정해야 합니다. Docker는 로컬 개발용 기본 계정을 가져오지 않습니다. SQLite DB와 자동 생성 JWT 키는 `translator-data` volume에 저장됩니다. 상세 설정, 백업 및 복원 절차는 `DEPLOYMENT.md`를 참고하세요.
## 자동 테스트 ## 자동 테스트
```bash ```bash

View file

@ -1,7 +1,8 @@
"""JWT-based authentication with config-file user management.""" """JWT authentication and SQLite-backed administrator-managed users."""
import os import json
import logging import logging
import os
import secrets import secrets
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from pathlib import Path from pathlib import Path
@ -10,9 +11,9 @@ import bcrypt
import jwt import jwt
from dotenv import load_dotenv 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 HTTPAuthorizationCredentials, HTTPBearer
# ── Config ──────────────────────────────────────────── from backend.database import database
PROJECT_ROOT = Path(__file__).parent.parent PROJECT_ROOT = Path(__file__).parent.parent
load_dotenv(PROJECT_ROOT / ".env") load_dotenv(PROJECT_ROOT / ".env")
@ -22,113 +23,201 @@ def _load_jwt_secret() -> str:
configured = os.getenv("JWT_SECRET_KEY") configured = os.getenv("JWT_SECRET_KEY")
if configured: if configured:
return configured return configured
secret_path = Path( secret_path = Path(
os.getenv("JWT_SECRET_FILE", str(PROJECT_ROOT / "config" / ".jwt-secret")) os.getenv("JWT_SECRET_FILE", str(PROJECT_ROOT / "data" / ".jwt-secret"))
) )
secret_path.parent.mkdir(parents=True, exist_ok=True)
try: try:
secret = secret_path.read_text(encoding="utf-8").strip() secret = secret_path.read_text(encoding="utf-8").strip()
if secret: if secret:
return secret return secret
except FileNotFoundError: except FileNotFoundError:
pass pass
secret = secrets.token_urlsafe(48) secret = secrets.token_urlsafe(48)
try: try:
descriptor = os.open(secret_path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) descriptor = os.open(secret_path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
except FileExistsError: except FileExistsError:
secret = secret_path.read_text(encoding="utf-8").strip() existing = secret_path.read_text(encoding="utf-8").strip()
if secret: if existing:
return secret return existing
raise RuntimeError(f"JWT secret file is empty: {secret_path}") 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: with os.fdopen(descriptor, "w", encoding="utf-8") as secret_file:
secret_file.write(secret) secret_file.write(secret)
logging.getLogger(__name__).warning("Created JWT secret file at %s", secret_path) logging.getLogger(__name__).warning("Created JWT secret file at %s", secret_path)
return secret return secret
JWT_SECRET_KEY = _load_jwt_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(auto_error=False) security = HTTPBearer(auto_error=False)
# ── User Loading ─────────────────────────────────────── def _migrate_users() -> None:
"""Import users.json exactly once, then optionally bootstrap an administrator."""
logger = logging.getLogger(__name__)
with database._lock, database.connect() as connection:
marker = connection.execute(
"SELECT value FROM schema_meta WHERE key = 'users_json_v1'"
).fetchone()
if marker is None:
config_path = Path(
os.getenv("USERS_CONFIG_PATH", str(PROJECT_ROOT / "config" / "users.json"))
)
records: list[tuple[str, str, int]] = []
sanitized_users: list[dict] = []
contained_plaintext = False
try:
raw = json.loads(config_path.read_text(encoding="utf-8"))
if not isinstance(raw, list):
raise ValueError("users.json must contain a list")
seen: set[str] = set()
for item in raw:
user_id = str(item.get("id") or "").strip()
if not user_id or user_id in seen:
raise ValueError("users.json contains an empty or duplicate id")
seen.add(user_id)
password_hash = item.get("password")
if not password_hash and item.get("password_plain"):
contained_plaintext = True
password_hash = bcrypt.hashpw(
str(item["password_plain"]).encode(), bcrypt.gensalt()
).decode()
if not password_hash:
raise ValueError(f"missing password for user {user_id}")
try:
bcrypt.checkpw(b"hash-validation", password_hash.encode())
except ValueError as exc:
raise ValueError(f"invalid bcrypt hash for user {user_id}") from exc
records.append(
(user_id, password_hash, int(bool(item.get("is_admin") or user_id == "admin")))
)
sanitized = dict(item)
sanitized.pop("password_plain", None)
sanitized["password"] = password_hash
sanitized_users.append(sanitized)
except FileNotFoundError:
records = []
except (json.JSONDecodeError, ValueError) as exc:
raise RuntimeError(f"Cannot migrate users from {config_path}: {exc}") from exc
def _load_users_file(): if contained_plaintext:
"""Load users from config file. Auto-hash plain-text passwords on first run.""" temporary_path = config_path.with_suffix(config_path.suffix + ".tmp")
import json as j try:
config_path = os.getenv( temporary_path.write_text(
"USERS_CONFIG_PATH", str(Path(__file__).parent.parent / "config" / "users.json") json.dumps(sanitized_users, ensure_ascii=False, indent=2) + "\n",
) encoding="utf-8",
with open(config_path, encoding="utf-8") as f: )
users_raw = j.load(f) os.replace(temporary_path, config_path)
except OSError as exc:
temporary_path.unlink(missing_ok=True)
raise RuntimeError(
f"Cannot remove plaintext passwords from {config_path}; "
"make the file writable for the first migration"
) from exc
# Migrate plain-text passwords to bcrypt hashes (one-time on startup) now = datetime.now(timezone.utc).isoformat()
migrated = False connection.execute("BEGIN IMMEDIATE")
for user in users_raw: try:
if "password_plain" in user and "password" not in user: for user_id, password_hash, is_admin in records:
pw_bytes = user.pop("password_plain").encode("utf-8") connection.execute(
user["password"] = bcrypt.hashpw(pw_bytes, bcrypt.gensalt()).decode("utf-8") "INSERT OR IGNORE INTO users(id, password_hash, is_admin, is_active, "
migrated = True "token_version, created_at, updated_at) VALUES(?, ?, ?, 1, 0, ?, ?)",
(user_id, password_hash, is_admin, now, now),
)
connection.execute(
"INSERT OR IGNORE INTO schema_meta(key, value) VALUES('users_json_v1', ?)",
(now,),
)
connection.execute("COMMIT")
except Exception:
connection.execute("ROLLBACK")
raise
logger.info("Imported %d user(s) from users.json", len(records))
if migrated: if database.count_admins() == 0:
with open(config_path, "w", encoding="utf-8") as f: admin_id = os.getenv("BOOTSTRAP_ADMIN_ID")
j.dump(users_raw, f, ensure_ascii=False, indent=2) admin_password = os.getenv("BOOTSTRAP_ADMIN_PASSWORD")
if admin_id and admin_password:
return users_raw password_hash = bcrypt.hashpw(admin_password.encode(), bcrypt.gensalt()).decode()
if database.get_user(admin_id):
database.update_user(
admin_id,
password_hash=password_hash,
is_admin=True,
is_active=True,
)
else:
database.create_user(admin_id, password_hash, is_admin=True)
logger.warning("Created bootstrap administrator %s", admin_id)
else:
raise RuntimeError(
"No active administrator exists. Mark a users.json account as admin or "
"provide BOOTSTRAP_ADMIN_ID and BOOTSTRAP_ADMIN_PASSWORD."
)
_users_db = _load_users_file() _migrate_users()
def get_user_by_username(username: str): def get_user_by_username(username: str):
for u in _users_db: return database.get_user(username)
if u["id"] == username:
return u
return None
# ── Token Helpers ─────────────────────────────────────
def create_access_token(data: dict) -> str: def create_access_token(data: dict) -> str:
to_encode = data.copy() payload = data.copy()
expire = datetime.now(timezone.utc) + timedelta(hours=JWT_EXPIRE_HOURS) payload["exp"] = datetime.now(timezone.utc) + timedelta(hours=JWT_EXPIRE_HOURS)
to_encode.update({"exp": expire}) return jwt.encode(payload, JWT_SECRET_KEY, algorithm=JWT_ALGORITHM)
return jwt.encode(to_encode, JWT_SECRET_KEY, algorithm=JWT_ALGORITHM)
def decode_access_token(token: str) -> dict | None: def decode_access_token(token: str) -> dict | None:
try: try:
return jwt.decode(token, JWT_SECRET_KEY, algorithms=[JWT_ALGORITHM]) return jwt.decode(token, JWT_SECRET_KEY, algorithms=[JWT_ALGORITHM])
except jwt.ExpiredSignatureError: except (jwt.ExpiredSignatureError, jwt.InvalidTokenError):
return None
except jwt.InvalidTokenError:
return None return None
# ── Dependency ─────────────────────────────────────── def _authenticated_user(credentials: HTTPAuthorizationCredentials | None) -> dict:
async def get_current_user(
credentials: HTTPAuthorizationCredentials | None = Depends(security),
) -> str:
"""Extract and validate JWT, returning the user_id."""
if credentials is None: if credentials is None:
raise HTTPException( raise HTTPException(status_code=401, detail="인증이 필요합니다")
status_code=status.HTTP_401_UNAUTHORIZED,
detail="인증이 필요합니다",
)
payload = decode_access_token(credentials.credentials) payload = decode_access_token(credentials.credentials)
user_id = payload.get("user_id") if payload else None 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: user = database.get_user(user_id) if isinstance(user_id, str) else None
if (
user is None
or not user["is_active"]
or payload.get("token_version") != user["token_version"]
):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="유효하지 않거나 만료된 토큰입니다", detail="유효하지 않거나 만료된 토큰입니다",
) )
return user_id return user
async def get_current_user(
credentials: HTTPAuthorizationCredentials | None = Depends(security),
) -> str:
return str(_authenticated_user(credentials)["id"])
async def require_admin(
credentials: HTTPAuthorizationCredentials | None = Depends(security),
) -> str:
user = _authenticated_user(credentials)
if not user["is_admin"]:
raise HTTPException(status_code=403, detail="관리자 권한이 필요합니다")
return str(user["id"])
def hash_password(password: str) -> str:
return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
__all__ = [
"create_access_token",
"get_current_user",
"get_user_by_username",
"hash_password",
"require_admin",
]

459
backend/database.py Normal file
View file

@ -0,0 +1,459 @@
"""SQLite persistence for users, translation sessions, and one prior revision."""
import json
import os
import sqlite3
import threading
import uuid
from contextlib import contextmanager
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Iterator
from dotenv import load_dotenv
from backend.models import TranslationSession
PROJECT_ROOT = Path(__file__).parent.parent
load_dotenv(PROJECT_ROOT / ".env")
class SessionConflictError(Exception):
"""Raised when an optimistic session update uses a stale version."""
class SessionArchivedError(Exception):
"""Raised when content mutation is attempted on an archived session."""
class LastAdminError(Exception):
"""Raised when an update would remove the final active administrator."""
def _now() -> str:
return datetime.now(timezone.utc).isoformat()
def _default_title(source_text: str) -> str:
first_line = next((line.strip() for line in source_text.splitlines() if line.strip()), "")
return first_line[:80] or "새 번역"
class Database:
def __init__(self, path: str | Path | None = None):
configured = path or os.getenv(
"DATABASE_PATH",
str(PROJECT_ROOT / "data" / "translator.db"),
)
self.path = Path(configured)
self.path.parent.mkdir(parents=True, exist_ok=True)
self._lock = threading.RLock()
self.initialize()
@contextmanager
def connect(self) -> Iterator[sqlite3.Connection]:
connection = sqlite3.connect(self.path, timeout=10, isolation_level=None)
connection.row_factory = sqlite3.Row
connection.execute("PRAGMA foreign_keys = ON")
connection.execute("PRAGMA busy_timeout = 10000")
try:
yield connection
finally:
connection.close()
def initialize(self) -> None:
with self._lock, self.connect() as connection:
connection.execute("PRAGMA journal_mode = WAL")
connection.executescript(
"""
CREATE TABLE IF NOT EXISTS schema_meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
password_hash TEXT NOT NULL,
is_admin INTEGER NOT NULL DEFAULT 0,
is_active INTEGER NOT NULL DEFAULT 1,
token_version INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS translation_sessions (
id TEXT PRIMARY KEY,
owner_id TEXT NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
title TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'draft',
current_phase INTEGER NOT NULL DEFAULT 0,
version INTEGER NOT NULL DEFAULT 0,
data_json TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
completed_at TEXT,
archived_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_sessions_owner_updated
ON translation_sessions(owner_id, archived_at, updated_at DESC);
CREATE TABLE IF NOT EXISTS session_revisions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL UNIQUE
REFERENCES translation_sessions(id) ON DELETE CASCADE,
snapshot_json TEXT NOT NULL,
reason TEXT NOT NULL,
created_at TEXT NOT NULL
);
"""
)
connection.execute(
"INSERT OR REPLACE INTO schema_meta(key, value) VALUES('schema_version', '1')"
)
# Users
def count_users(self) -> int:
with self.connect() as connection:
return int(connection.execute("SELECT COUNT(*) FROM users").fetchone()[0])
def count_admins(self) -> int:
with self.connect() as connection:
return int(
connection.execute(
"SELECT COUNT(*) FROM users WHERE is_admin = 1 AND is_active = 1"
).fetchone()[0]
)
def get_user(self, user_id: str) -> dict[str, Any] | None:
with self.connect() as connection:
row = connection.execute(
"SELECT * FROM users WHERE id = ?", (user_id,)
).fetchone()
return dict(row) if row else None
def list_users(self) -> list[dict[str, Any]]:
with self.connect() as connection:
rows = connection.execute(
"SELECT id, is_admin, is_active, created_at, updated_at "
"FROM users ORDER BY id"
).fetchall()
return [dict(row) for row in rows]
def create_user(
self, user_id: str, password_hash: str, *, is_admin: bool = False
) -> None:
now = _now()
with self._lock, self.connect() as connection:
connection.execute(
"INSERT INTO users(id, password_hash, is_admin, is_active, "
"token_version, created_at, updated_at) VALUES(?, ?, ?, 1, 0, ?, ?)",
(user_id, password_hash, int(is_admin), now, now),
)
def update_user(
self,
user_id: str,
*,
password_hash: str | None = None,
is_admin: bool | None = None,
is_active: bool | None = None,
) -> bool:
assignments: list[str] = []
values: list[Any] = []
invalidate_tokens = False
if password_hash is not None:
assignments.append("password_hash = ?")
values.append(password_hash)
invalidate_tokens = True
if is_admin is not None:
assignments.append("is_admin = ?")
values.append(int(is_admin))
if is_active is not None:
assignments.append("is_active = ?")
values.append(int(is_active))
if not is_active:
invalidate_tokens = True
if invalidate_tokens:
assignments.append("token_version = token_version + 1")
if not assignments:
return self.get_user(user_id) is not None
assignments.append("updated_at = ?")
values.extend([_now(), user_id])
with self._lock, self.connect() as connection:
connection.execute("BEGIN IMMEDIATE")
try:
current = connection.execute(
"SELECT is_admin, is_active FROM users WHERE id = ?", (user_id,)
).fetchone()
if not current:
connection.execute("ROLLBACK")
return False
removes_admin = current["is_admin"] and current["is_active"] and (
is_admin is False or is_active is False
)
if removes_admin:
admin_count = connection.execute(
"SELECT COUNT(*) FROM users WHERE is_admin = 1 AND is_active = 1"
).fetchone()[0]
if admin_count <= 1:
connection.execute("ROLLBACK")
raise LastAdminError
cursor = connection.execute(
f"UPDATE users SET {', '.join(assignments)} WHERE id = ?", values
)
connection.execute("COMMIT")
return cursor.rowcount == 1
except Exception:
if connection.in_transaction:
connection.execute("ROLLBACK")
raise
# Sessions
def create_session(
self, session: TranslationSession, owner_id: str, title: str | None = None
) -> str:
session_id = uuid.uuid4().hex[:16]
now = _now()
data = session.model_copy(update={"progress": None}).model_dump(mode="json")
phase = self._phase_from_data(data)
status = "completed" if phase >= 4 else "draft"
with self._lock, self.connect() as connection:
connection.execute(
"INSERT INTO translation_sessions(id, owner_id, title, status, current_phase, "
"data_json, created_at, updated_at, completed_at) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)",
(
session_id,
owner_id,
title or _default_title(session.source_text),
status,
phase,
json.dumps(data, ensure_ascii=False),
now,
now,
now if phase >= 4 else None,
),
)
return session_id
def get_session(
self, session_id: str, owner_id: str
) -> tuple[TranslationSession, int, dict[str, Any]] | None:
with self.connect() as connection:
row = connection.execute(
"SELECT * FROM translation_sessions WHERE id = ? AND owner_id = ?",
(session_id, owner_id),
).fetchone()
if not row:
return None
metadata = dict(row)
return TranslationSession(**json.loads(row["data_json"])), row["version"], metadata
def update_session(
self,
session_id: str,
owner_id: str,
patch: dict[str, Any],
*,
expected_version: int | None = None,
snapshot_reason: str | None = None,
current_phase: int | None = None,
) -> tuple[TranslationSession, int] | None:
with self._lock, self.connect() as connection:
connection.execute("BEGIN IMMEDIATE")
try:
row = connection.execute(
"SELECT * FROM translation_sessions WHERE id = ? AND owner_id = ?",
(session_id, owner_id),
).fetchone()
if not row:
connection.execute("ROLLBACK")
return None
if row["archived_at"] is not None:
connection.execute("ROLLBACK")
raise SessionArchivedError
if expected_version is not None and row["version"] != expected_version:
connection.execute("ROLLBACK")
raise SessionConflictError
old_data = json.loads(row["data_json"])
if snapshot_reason and any(
old_data.get(key)
for key in ("phase1_result", "phase3_result", "phase4_result")
):
connection.execute(
"INSERT INTO session_revisions(session_id, snapshot_json, reason, created_at) "
"VALUES(?, ?, ?, ?) ON CONFLICT(session_id) DO UPDATE SET "
"snapshot_json = excluded.snapshot_json, reason = excluded.reason, "
"created_at = excluded.created_at",
(session_id, row["data_json"], snapshot_reason, _now()),
)
merged = {**old_data, **patch, "progress": None}
session = TranslationSession(**merged)
version = row["version"] + 1
phase = current_phase if current_phase is not None else row["current_phase"]
status = "completed" if phase >= 4 else "draft"
completed_at = _now() if phase >= 4 else None
title = row["title"]
if patch.get("source_text") is not None and title in {"새 번역", _default_title(old_data.get("source_text", ""))}:
title = _default_title(session.source_text)
cursor = connection.execute(
"UPDATE translation_sessions SET title = ?, status = ?, current_phase = ?, "
"version = ?, data_json = ?, updated_at = ?, completed_at = ? "
"WHERE id = ? AND owner_id = ? AND version = ?",
(
title,
status,
phase,
version,
json.dumps(session.model_dump(mode="json"), ensure_ascii=False),
_now(),
completed_at,
session_id,
owner_id,
row["version"],
),
)
if cursor.rowcount != 1:
connection.execute("ROLLBACK")
raise SessionConflictError
connection.execute("COMMIT")
return session, version
except Exception:
if connection.in_transaction:
connection.execute("ROLLBACK")
raise
def list_sessions(
self, owner_id: str, *, include_archived: bool = False, limit: int = 50
) -> list[dict[str, Any]]:
archived_clause = "" if include_archived else "AND archived_at IS NULL"
with self.connect() as connection:
rows = connection.execute(
f"SELECT id, title, status, current_phase, version, data_json, "
f"created_at, updated_at, completed_at, archived_at "
f"FROM translation_sessions WHERE owner_id = ? {archived_clause} "
f"ORDER BY updated_at DESC LIMIT ?",
(owner_id, min(max(limit, 1), 100)),
).fetchall()
summaries = []
for row in rows:
data = json.loads(row["data_json"])
summaries.append(
{
"session_id": row["id"],
"title": row["title"],
"source_preview": data.get("source_text", "")[:120],
"target_language": data.get("target_language", "한국어"),
"status": row["status"],
"current_phase": row["current_phase"],
"version": row["version"],
"created_at": row["created_at"],
"updated_at": row["updated_at"],
"archived_at": row["archived_at"],
}
)
return summaries
def set_archived(self, session_id: str, owner_id: str, archived: bool) -> bool:
with self._lock, self.connect() as connection:
row = connection.execute(
"SELECT current_phase FROM translation_sessions WHERE id = ? AND owner_id = ?",
(session_id, owner_id),
).fetchone()
if not row:
return False
cursor = connection.execute(
"UPDATE translation_sessions SET archived_at = ?, status = ?, updated_at = ?, "
"version = version + 1 "
"WHERE id = ? AND owner_id = ?",
(
_now() if archived else None,
"archived" if archived else ("completed" if row["current_phase"] >= 4 else "draft"),
_now(),
session_id,
owner_id,
),
)
return cursor.rowcount == 1
@staticmethod
def _phase_from_data(data: dict[str, Any]) -> int:
if data.get("phase4_result"):
return 4
if data.get("phase3_result"):
return 3
if data.get("phase2_confirmed"):
return 2
if data.get("phase1_result"):
return 1
return 0
def rename_session(self, session_id: str, owner_id: str, title: str) -> bool:
with self._lock, self.connect() as connection:
cursor = connection.execute(
"UPDATE translation_sessions SET title = ?, updated_at = ? "
"WHERE id = ? AND owner_id = ?",
(title.strip()[:120] or "새 번역", _now(), session_id, owner_id),
)
return cursor.rowcount == 1
def delete_session(self, session_id: str, owner_id: str) -> bool:
with self._lock, self.connect() as connection:
cursor = connection.execute(
"DELETE FROM translation_sessions WHERE id = ? AND owner_id = ?",
(session_id, owner_id),
)
return cursor.rowcount == 1
def clone_session(self, session_id: str, owner_id: str) -> str | None:
result = self.get_session(session_id, owner_id)
if not result:
return None
session, _, metadata = result
clone = session.model_copy(update={"progress": None})
return self.create_session(clone, owner_id, f"{metadata['title']} (복사본)")
def get_revision(self, session_id: str, owner_id: str) -> dict[str, Any] | None:
with self.connect() as connection:
row = connection.execute(
"SELECT r.snapshot_json, r.reason, r.created_at FROM session_revisions r "
"JOIN translation_sessions s ON s.id = r.session_id "
"WHERE r.session_id = ? AND s.owner_id = ?",
(session_id, owner_id),
).fetchone()
if not row:
return None
return {
"data": json.loads(row["snapshot_json"]),
"reason": row["reason"],
"created_at": row["created_at"],
}
def restore_revision(self, session_id: str, owner_id: str) -> bool:
revision = self.get_revision(session_id, owner_id)
if not revision:
return False
result = self.get_session(session_id, owner_id)
if not result:
return False
_, version, _ = result
data = revision["data"]
phase = self._phase_from_data(data)
restored = self.update_session(
session_id,
owner_id,
data,
expected_version=version,
snapshot_reason="revision_restore",
current_phase=phase,
)
return restored is not None
def healthcheck(self) -> bool:
with self.connect() as connection:
return connection.execute("SELECT 1").fetchone()[0] == 1
database = Database()

View file

@ -3,6 +3,7 @@
import json import json
import os import os
import re import re
import sqlite3
from collections import Counter from collections import Counter
from pathlib import Path from pathlib import Path
from time import monotonic from time import monotonic
@ -14,7 +15,8 @@ from openai import APITimeoutError, OpenAIError
from pydantic import ValidationError 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, hash_password, require_admin
from backend.database import LastAdminError, database
from backend.llm_client import ( from backend.llm_client import (
chat_complete, chat_complete,
get_llm_configs, get_llm_configs,
@ -22,16 +24,20 @@ from backend.llm_client import (
) )
from backend.models import ( from backend.models import (
CreateSessionRequest, CreateSessionRequest,
CreateUserRequest,
LoginRequest, LoginRequest,
Phase1Result, Phase1Result,
Phase2ConfirmRequest, Phase2ConfirmRequest,
PhaseUpdateRequest, PhaseUpdateRequest,
ProperNoun, ProperNoun,
SessionResponse, SessionResponse,
SessionSummary,
TokenResponse, TokenResponse,
TranslationChunk, TranslationChunk,
UserInfo, UserInfo,
TranslationSession, TranslationSession,
UpdateUserRequest,
UserSummary,
) )
from backend.prompts import ( from backend.prompts import (
SYSTEM_PROMPT_PHASE1, SYSTEM_PROMPT_PHASE1,
@ -41,7 +47,7 @@ from backend.prompts import (
build_phase3_user_prompt, build_phase3_user_prompt,
build_phase4_user_prompt, build_phase4_user_prompt,
) )
from backend.sessions import SessionConflictError, session_store from backend.sessions import SessionArchivedError, SessionConflictError, session_store
# ── App Setup ──────────────────────────────────────── # ── App Setup ────────────────────────────────────────
@ -69,20 +75,63 @@ async def login(req: LoginRequest):
user = auth_mod.get_user_by_username(req.id) user = auth_mod.get_user_by_username(req.id)
try: try:
authenticated = user is not None and _bcrypt.checkpw( 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_hash"].encode("utf-8")
) )
except (KeyError, ValueError): except (KeyError, ValueError):
authenticated = False authenticated = False
if not authenticated: 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"]}) if not user["is_active"]:
raise HTTPException(status_code=401, detail="비활성화된 계정입니다")
token = auth_mod.create_access_token(
{"user_id": user["id"], "token_version": user["token_version"]}
)
return TokenResponse(access_token=token) return TokenResponse(access_token=token)
@app.get("/api/auth/me") @app.get("/api/auth/me")
async def me(user_id: str = Depends(get_current_user)): async def me(user_id: str = Depends(get_current_user)):
return UserInfo(user_id=user_id) user = database.get_user(user_id)
return UserInfo(user_id=user_id, is_admin=bool(user["is_admin"]))
@app.get("/api/admin/users")
async def list_users(_admin_id: str = Depends(require_admin)):
return [UserSummary(**user).model_dump() for user in database.list_users()]
@app.post("/api/admin/users", status_code=201)
async def create_user(req: CreateUserRequest, _admin_id: str = Depends(require_admin)):
try:
database.create_user(req.id, hash_password(req.password), is_admin=req.is_admin)
except sqlite3.IntegrityError as exc:
raise HTTPException(status_code=409, detail="이미 존재하는 사용자입니다") from exc
return {"created": True, "id": req.id}
@app.patch("/api/admin/users/{target_user_id}")
async def update_user(
target_user_id: str,
req: UpdateUserRequest,
admin_id: str = Depends(require_admin),
):
if target_user_id == admin_id and (req.is_active is False or req.is_admin is False):
raise HTTPException(status_code=400, detail="자기 관리자 권한을 제거할 수 없습니다")
try:
updated = database.update_user(
target_user_id,
password_hash=hash_password(req.password) if req.password else None,
is_admin=req.is_admin,
is_active=req.is_active,
)
except LastAdminError as exc:
raise HTTPException(
status_code=409, detail="최소 한 명의 활성 관리자가 필요합니다"
) from exc
if not updated:
raise HTTPException(status_code=404, detail="사용자를 찾을 수 없습니다")
return {"updated": True}
# ── LLM Config Routes ──────────────────────────────── # ── LLM Config Routes ────────────────────────────────
@ -117,16 +166,28 @@ async def create_session(
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, user_id) session_id = session_store.db.create_session(session_data, user_id, req.title)
return {"session_id": session_id} return {"session_id": session_id}
@app.get("/api/sessions")
async def list_sessions(
include_archived: bool = False,
limit: int = 50,
user_id: str = Depends(get_current_user),
):
return [
SessionSummary(**item).model_dump()
for item in session_store.list(user_id, include_archived, limit)
]
@app.get("/api/sessions/{session_id}") @app.get("/api/sessions/{session_id}")
async def get_session(session_id: str, user_id: str = Depends(get_current_user)): async def get_session(session_id: str, user_id: str = Depends(get_current_user)):
data = session_store.get(session_id, user_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 _session_response(session_id, user_id, data)
@app.get("/api/sessions/{session_id}/progress") @app.get("/api/sessions/{session_id}/progress")
@ -147,6 +208,48 @@ async def delete_session(session_id: str, user_id: str = Depends(get_current_use
return {"deleted": True} return {"deleted": True}
@app.post("/api/sessions/{session_id}/archive")
async def archive_session(session_id: str, user_id: str = Depends(get_current_user)):
if not session_store.archive(session_id, user_id):
raise HTTPException(status_code=404, detail="세션을 찾을 수 없습니다")
return {"archived": True}
@app.post("/api/sessions/{session_id}/restore")
async def restore_session(session_id: str, user_id: str = Depends(get_current_user)):
if not session_store.archive(session_id, user_id, archived=False):
raise HTTPException(status_code=404, detail="세션을 찾을 수 없습니다")
return {"restored": True}
@app.post("/api/sessions/{session_id}/clone", status_code=201)
async def clone_session(session_id: str, user_id: str = Depends(get_current_user)):
cloned_id = session_store.clone(session_id, user_id)
if not cloned_id:
raise HTTPException(status_code=404, detail="세션을 찾을 수 없습니다")
return {"session_id": cloned_id}
@app.get("/api/sessions/{session_id}/revision")
async def get_revision(session_id: str, user_id: str = Depends(get_current_user)):
revision = session_store.get_revision(session_id, user_id)
if not revision:
raise HTTPException(status_code=404, detail="저장된 직전 버전이 없습니다")
return revision
@app.post("/api/sessions/{session_id}/revision/restore")
async def restore_revision(session_id: str, user_id: str = Depends(get_current_user)):
try:
restored = session_store.restore_revision(session_id, user_id)
except SessionArchivedError as exc:
raise HTTPException(status_code=409, detail="보관된 세션은 수정할 수 없습니다") from exc
if not restored:
raise HTTPException(status_code=404, detail="저장된 직전 버전이 없습니다")
data = session_store.get(session_id, user_id)
return _session_response(session_id, user_id, data)
@app.patch("/api/sessions/{session_id}") @app.patch("/api/sessions/{session_id}")
async def update_session( async def update_session(
session_id: str, session_id: str,
@ -154,6 +257,8 @@ async def update_session(
user_id: str = Depends(get_current_user), user_id: str = Depends(get_current_user),
): ):
data = req.model_dump(exclude_none=True) data = req.model_dump(exclude_none=True)
title = data.pop("title", None)
expected_version = data.pop("expected_version", None)
_validate_model_aliases(data) _validate_model_aliases(data)
current = session_store.get(session_id, user_id) current = session_store.get(session_id, user_id)
if current is None: if current is None:
@ -166,6 +271,7 @@ async def update_session(
"phase1_chunks": [], "phase1_chunks": [],
"phase2_proper_nouns": [], "phase2_proper_nouns": [],
"phase2_style": "", "phase2_style": "",
"phase2_confirmed": False,
"phase3_result": "", "phase3_result": "",
"phase3_chunks": [], "phase3_chunks": [],
"phase4_result": "", "phase4_result": "",
@ -181,10 +287,34 @@ async def update_session(
elif changed & {"model_phase4"}: elif changed & {"model_phase4"}:
data.update({"phase4_result": "", "progress": None}) data.update({"phase4_result": "", "progress": None})
updated = session_store.update(session_id, data, user_id) snapshot_reason = "session_edit" if changed else None
current_phase = None
if changed & {"source_text", "source_language", "target_language", "model_phase1"}:
current_phase = 0
elif changed & {"model_phase3"}:
current_phase = 2 if current.phase2_confirmed else (1 if current.phase1_result else 0)
elif changed & {"model_phase4"}:
current_phase = 3 if current.phase3_result else (1 if current.phase1_result else 0)
try:
updated = session_store.update(
session_id,
data,
user_id,
expected_version=expected_version,
snapshot_reason=snapshot_reason,
current_phase=current_phase,
)
except SessionConflictError as exc:
raise HTTPException(
status_code=409, detail="다른 변경이 먼저 저장되었습니다. 세션을 다시 불러오세요"
) from exc
except SessionArchivedError as exc:
raise HTTPException(status_code=409, detail="보관된 세션은 수정할 수 없습니다") from exc
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() if title is not None:
session_store.rename(session_id, user_id, title)
return _session_response(session_id, user_id, updated)
# ── Phase Execution Routes ─────────────────────────── # ── Phase Execution Routes ───────────────────────────
@ -196,6 +326,7 @@ async def run_phase1(session_id: str, user_id: str = Depends(get_current_user)):
if snapshot is None: if snapshot is None:
raise HTTPException(status_code=404, detail="세션을 찾을 수 없습니다") raise HTTPException(status_code=404, detail="세션을 찾을 수 없습니다")
session, version = snapshot session, version = snapshot
_ensure_not_archived(session_id, user_id)
alias = session.model_phase1 alias = session.model_phase1
if not alias: if not alias:
@ -247,16 +378,24 @@ async def run_phase1(session_id: str, user_id: str = Depends(get_current_user)):
], ],
"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,
"phase2_confirmed": False,
"phase3_result": "", "phase3_result": "",
"phase3_chunks": [], "phase3_chunks": [],
"phase4_result": "", "phase4_result": "",
"progress": None, "progress": None,
} }
updated = _update_phase_result(session_id, update_data, user_id, version) updated = _update_phase_result(
session_id,
update_data,
user_id,
version,
snapshot_reason="phase1_rerun",
current_phase=1,
)
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 _session_response(session_id, user_id, updated)
@app.post("/api/translate/{session_id}/phase2") @app.post("/api/translate/{session_id}/phase2")
@ -269,20 +408,38 @@ async def run_phase2(
session = session_store.get(session_id, user_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="세션을 찾을 수 없습니다")
if session.phase1_result is None:
raise HTTPException(status_code=400, detail="먼저 Phase 1을 실행하세요")
_ensure_not_archived(session_id, user_id)
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,
"phase2_confirmed": True,
"phase3_result": "", "phase3_result": "",
"phase3_chunks": [], "phase3_chunks": [],
"phase4_result": "", "phase4_result": "",
"progress": None, "progress": None,
} }
updated = session_store.update(session_id, update_data, user_id) try:
updated = session_store.update(
session_id,
update_data,
user_id,
expected_version=req.expected_version,
snapshot_reason="phase2_edit",
current_phase=2,
)
except SessionConflictError as exc:
raise HTTPException(
status_code=409, detail="다른 변경이 먼저 저장되었습니다. 세션을 다시 불러오세요"
) from exc
except SessionArchivedError as exc:
raise HTTPException(status_code=409, detail="보관된 세션은 수정할 수 없습니다") from exc
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 _session_response(session_id, user_id, updated)
@app.post("/api/translate/{session_id}/phase3") @app.post("/api/translate/{session_id}/phase3")
@ -292,6 +449,7 @@ async def run_phase3(session_id: str, user_id: str = Depends(get_current_user)):
if snapshot is None: if snapshot is None:
raise HTTPException(status_code=404, detail="세션을 찾을 수 없습니다") raise HTTPException(status_code=404, detail="세션을 찾을 수 없습니다")
session, version = snapshot session, version = snapshot
_ensure_not_archived(session_id, user_id)
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을 실행하세요")
@ -334,11 +492,18 @@ async def run_phase3(session_id: str, user_id: str = Depends(get_current_user)):
"phase4_result": "", "phase4_result": "",
"progress": None, "progress": None,
} }
updated = _update_phase_result(session_id, update_data, user_id, version) updated = _update_phase_result(
session_id,
update_data,
user_id,
version,
snapshot_reason="phase3_rerun",
current_phase=3,
)
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 _session_response(session_id, user_id, updated)
@app.post("/api/translate/{session_id}/phase4") @app.post("/api/translate/{session_id}/phase4")
@ -348,6 +513,7 @@ async def run_phase4(session_id: str, user_id: str = Depends(get_current_user)):
if snapshot is None: if snapshot is None:
raise HTTPException(status_code=404, detail="세션을 찾을 수 없습니다") raise HTTPException(status_code=404, detail="세션을 찾을 수 없습니다")
session, version = snapshot session, version = snapshot
_ensure_not_archived(session_id, user_id)
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을 실행하세요")
@ -385,11 +551,18 @@ async def run_phase4(session_id: str, user_id: str = Depends(get_current_user)):
"phase4_result": "\n\n".join(result_chunks), "phase4_result": "\n\n".join(result_chunks),
"progress": None, "progress": None,
} }
updated = _update_phase_result(session_id, update_data, user_id, version) updated = _update_phase_result(
session_id,
update_data,
user_id,
version,
snapshot_reason="phase4_rerun",
current_phase=4,
)
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 _session_response(session_id, user_id, updated)
# ── Helpers ─────────────────────────────────────────── # ── Helpers ───────────────────────────────────────────
@ -627,17 +800,55 @@ async def _chat_complete(
def _update_phase_result( def _update_phase_result(
session_id: str, data: dict, user_id: str, expected_version: int session_id: str,
data: dict,
user_id: str,
expected_version: int,
*,
snapshot_reason: str,
current_phase: int,
) -> TranslationSession | None: ) -> TranslationSession | None:
try: try:
return session_store.update( return session_store.update(
session_id, data, user_id, expected_version=expected_version session_id,
data,
user_id,
expected_version=expected_version,
snapshot_reason=snapshot_reason,
current_phase=current_phase,
) )
except SessionConflictError as exc: except SessionConflictError as exc:
raise HTTPException( raise HTTPException(
status_code=409, status_code=409,
detail="처리 중 세션이 변경되었습니다. 현재 상태에서 단계를 다시 실행하세요", detail="처리 중 세션이 변경되었습니다. 현재 상태에서 단계를 다시 실행하세요",
) from exc ) from exc
except SessionArchivedError as exc:
raise HTTPException(status_code=409, detail="보관된 세션은 수정할 수 없습니다") from exc
def _session_response(
session_id: str, user_id: str, data: TranslationSession
) -> dict:
metadata = session_store.metadata(session_id, user_id)
if metadata is None:
raise HTTPException(status_code=404, detail="세션을 찾을 수 없습니다")
return SessionResponse(
session_id=session_id,
data=data,
title=metadata["title"],
status=metadata["status"],
current_phase=metadata["current_phase"],
version=metadata["version"],
created_at=metadata["created_at"],
updated_at=metadata["updated_at"],
archived_at=metadata["archived_at"],
).model_dump()
def _ensure_not_archived(session_id: str, user_id: str) -> None:
metadata = session_store.metadata(session_id, user_id)
if metadata and metadata["archived_at"] is not None:
raise HTTPException(status_code=409, detail="보관된 세션은 수정할 수 없습니다")
def _ensure_context_capacity(alias: str, content: str) -> None: def _ensure_context_capacity(alias: str, content: str) -> None:
@ -666,6 +877,18 @@ def _extract_json(text: str) -> str:
return text[start : end + 1] if start >= 0 and end > start else text return text[start : end + 1] if start >= 0 and end > start else text
@app.get("/api/health/live")
async def health_live():
return {"status": "ok"}
@app.get("/api/health/ready")
async def health_ready():
if not database.healthcheck() or not get_llm_configs():
raise HTTPException(status_code=503, detail="서비스가 준비되지 않았습니다")
return {"status": "ready"}
# ── Static File Serving (Frontend) ─────────────────── # ── Static File Serving (Frontend) ───────────────────
frontend_dist = os.getenv( frontend_dist = os.getenv(

View file

@ -17,6 +17,27 @@ class TokenResponse(BaseModel):
class UserInfo(BaseModel): class UserInfo(BaseModel):
user_id: str user_id: str
is_admin: bool = False
class CreateUserRequest(BaseModel):
id: str = Field(min_length=1, max_length=50, pattern=r"^[A-Za-z0-9_.-]+$")
password: str = Field(min_length=8, max_length=200)
is_admin: bool = False
class UpdateUserRequest(BaseModel):
password: str | None = Field(default=None, min_length=8, max_length=200)
is_admin: bool | None = None
is_active: bool | None = None
class UserSummary(BaseModel):
id: str
is_admin: bool
is_active: bool
created_at: str
updated_at: str
# ── LLM Config ─────────────────────────────────────── # ── LLM Config ───────────────────────────────────────
@ -42,7 +63,7 @@ class Phase1Result(BaseModel):
proper_nouns: list[ProperNoun] # 추출된 고유명사 리스트 proper_nouns: list[ProperNoun] # 추출된 고유명사 리스트
summary: str # 내용 요약 summary: str # 내용 요약
style: str # 문체 분석 결과 style: str # 문체 분석 결과
warnings: list[str] = [] # JSON 복구/폴백 등 품질 관련 알림 warnings: list[str] = Field(default_factory=list)
class TranslationChunk(BaseModel): class TranslationChunk(BaseModel):
@ -69,11 +90,12 @@ 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] = [] # 장문 분할 시 원문/초벌 번역 쌍 phase1_chunks: list[TranslationChunk] = Field(default_factory=list)
phase2_proper_nouns: list[ProperNoun] = [] # Phase 2에서 최종 확정된 고유명사 phase2_proper_nouns: list[ProperNoun] = Field(default_factory=list)
phase2_style: str = "" # Phase 2에서 최종 확정된 문체 phase2_style: str = "" # Phase 2에서 최종 확정된 문체
phase2_confirmed: bool = False
phase3_result: str = "" # 재번역 결과 phase3_result: str = "" # 재번역 결과
phase3_chunks: list[str] = [] # 장문 분할 재번역 결과 phase3_chunks: list[str] = Field(default_factory=list)
phase4_result: str = "" # 마무리 다듬기 결과 phase4_result: str = "" # 마무리 다듬기 결과
progress: PhaseProgress | None = None # 장기 작업 진행 상태 progress: PhaseProgress | None = None # 장기 작업 진행 상태
@ -88,6 +110,7 @@ class CreateSessionRequest(BaseModel):
model_phase2: str = "" model_phase2: str = ""
model_phase3: str = "" model_phase3: str = ""
model_phase4: str = "" model_phase4: str = ""
title: str | None = Field(default=None, max_length=120)
class PhaseUpdateRequest(BaseModel): class PhaseUpdateRequest(BaseModel):
@ -100,6 +123,8 @@ class PhaseUpdateRequest(BaseModel):
model_phase2: str | None = None model_phase2: str | None = None
model_phase3: str | None = None model_phase3: str | None = None
model_phase4: str | None = None model_phase4: str | None = None
title: str | None = Field(default=None, max_length=120)
expected_version: int
class Phase2ConfirmRequest(BaseModel): class Phase2ConfirmRequest(BaseModel):
@ -107,6 +132,7 @@ class Phase2ConfirmRequest(BaseModel):
proper_nouns: list[ProperNoun] proper_nouns: list[ProperNoun]
style: str style: str
expected_version: int
# ── API Responses ────────────────────────────────────── # ── API Responses ──────────────────────────────────────
@ -114,3 +140,29 @@ class Phase2ConfirmRequest(BaseModel):
class SessionResponse(BaseModel): class SessionResponse(BaseModel):
session_id: str session_id: str
data: TranslationSession data: TranslationSession
title: str = ""
status: str = "draft"
current_phase: int = 0
version: int = 0
created_at: str = ""
updated_at: str = ""
archived_at: str | None = None
class SessionSummary(BaseModel):
session_id: str
title: str
source_preview: str
target_language: str
status: str
current_phase: int
version: int
created_at: str
updated_at: str
archived_at: str | None = None
class RevisionResponse(BaseModel):
data: TranslationSession
reason: str
created_at: str

View file

@ -1,37 +1,25 @@
"""In-memory translation session store with 24h TTL.""" """Persistent translation session store with transient in-memory progress."""
import uuid import threading
from datetime import datetime, timezone, timedelta
from typing import Any from typing import Any
from backend.models import TranslationSession from backend.database import (
Database,
SessionArchivedError,
class SessionConflictError(Exception): SessionConflictError,
"""Raised when a long-running phase tries to update a changed session.""" database,
)
from backend.models import PhaseProgress, TranslationSession
class SessionStore: class SessionStore:
"""Process-local in-memory store for user-owned translation sessions. def __init__(self, db: Database | None = None):
self.db = db or database
Each session is keyed by a UUID and has a 24-hour TTL. self._progress: dict[tuple[str, str], dict[str, Any]] = {}
Expired sessions are lazily cleaned on access. self._progress_lock = threading.RLock()
"""
def __init__(self, ttl_hours: int = 24):
self._store: dict[str, dict[str, Any]] = {}
self.ttl_hours = ttl_hours
def create(self, session_data: TranslationSession, owner_id: str) -> str: def create(self, session_data: TranslationSession, owner_id: str) -> str:
session_id = uuid.uuid4().hex[:16] return self.db.create_session(session_data, owner_id)
self._store[session_id] = {
"data": session_data.model_dump(),
"owner_id": owner_id,
"version": 0,
"created_at": datetime.now(timezone.utc),
"updated_at": datetime.now(timezone.utc),
}
return session_id
def get(self, session_id: str, owner_id: str) -> TranslationSession | None: def get(self, session_id: str, owner_id: str) -> TranslationSession | None:
result = self.get_with_version(session_id, owner_id) result = self.get_with_version(session_id, owner_id)
@ -40,18 +28,15 @@ class SessionStore:
def get_with_version( def get_with_version(
self, session_id: str, owner_id: str self, session_id: str, owner_id: str
) -> tuple[TranslationSession, int] | None: ) -> tuple[TranslationSession, int] | None:
entry = self._store.get(session_id) result = self.db.get_session(session_id, owner_id)
if entry is None or entry["owner_id"] != owner_id: if not result:
return None return None
# Check TTL session, version, _ = result
now = datetime.now(timezone.utc) with self._progress_lock:
age = now - entry["updated_at"] progress = self._progress.get((owner_id, session_id))
if age > timedelta(hours=self.ttl_hours): if progress:
del self._store[session_id] session = session.model_copy(update={"progress": PhaseProgress(**progress)})
return None return session, version
# Touch (refresh TTL)
entry["updated_at"] = now
return TranslationSession(**entry["data"]), entry["version"]
def update( def update(
self, self,
@ -59,53 +44,68 @@ class SessionStore:
data: dict[str, Any], data: dict[str, Any],
owner_id: str, owner_id: str,
expected_version: int | None = None, expected_version: int | None = None,
*,
snapshot_reason: str | None = None,
current_phase: int | None = None,
) -> TranslationSession | None: ) -> TranslationSession | None:
entry = self._store.get(session_id) result = self.db.update_session(
if entry is None or entry["owner_id"] != owner_id: session_id,
owner_id,
data,
expected_version=expected_version,
snapshot_reason=snapshot_reason,
current_phase=current_phase,
)
if not result:
return None return None
now = datetime.now(timezone.utc) self.set_progress(session_id, None, owner_id)
if now - entry["updated_at"] > timedelta(hours=self.ttl_hours): return result[0]
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["version"] += 1
entry["updated_at"] = now
return TranslationSession(**entry["data"])
def delete(self, session_id: str, owner_id: str) -> bool: def delete(self, session_id: str, owner_id: str) -> bool:
entry = self._store.get(session_id) self.set_progress(session_id, None, owner_id)
if entry is not None and entry["owner_id"] == owner_id: return self.db.delete_session(session_id, owner_id)
del self._store[session_id]
return True
return False
def set_progress( def set_progress(
self, session_id: str, progress: dict[str, Any] | None, owner_id: str self, session_id: str, progress: dict[str, Any] | None, owner_id: str
) -> bool: ) -> bool:
"""Update transient progress without changing the semantic session version.""" key = (owner_id, session_id)
entry = self._store.get(session_id) with self._progress_lock:
if entry is None or entry["owner_id"] != owner_id: if progress is None:
return False self._progress.pop(key, None)
now = datetime.now(timezone.utc) else:
if now - entry["updated_at"] > timedelta(hours=self.ttl_hours): self._progress[key] = progress
del self._store[session_id]
return False
entry["data"]["progress"] = progress
entry["updated_at"] = now
return True return True
def cleanup_expired(self): def list(self, owner_id: str, include_archived: bool = False, limit: int = 50):
"""Remove all expired sessions.""" return self.db.list_sessions(
now = datetime.now(timezone.utc) owner_id, include_archived=include_archived, limit=limit
expired_keys = [ )
k for k, v in self._store.items()
if (now - v["updated_at"]) > timedelta(hours=self.ttl_hours) def metadata(self, session_id: str, owner_id: str) -> dict[str, Any] | None:
] result = self.db.get_session(session_id, owner_id)
for k in expired_keys: return result[2] if result else None
del self._store[k]
def rename(self, session_id: str, owner_id: str, title: str) -> bool:
return self.db.rename_session(session_id, owner_id, title)
def archive(self, session_id: str, owner_id: str, archived: bool = True) -> bool:
return self.db.set_archived(session_id, owner_id, archived)
def clone(self, session_id: str, owner_id: str) -> str | None:
return self.db.clone_session(session_id, owner_id)
def get_revision(self, session_id: str, owner_id: str):
return self.db.get_revision(session_id, owner_id)
def restore_revision(self, session_id: str, owner_id: str) -> bool:
return self.db.restore_revision(session_id, owner_id)
# Singleton instance session_store = SessionStore()
session_store = SessionStore(ttl_hours=24)
__all__ = [
"SessionArchivedError",
"SessionConflictError",
"SessionStore",
"session_store",
]

41
compose.yml Normal file
View file

@ -0,0 +1,41 @@
services:
translator:
build:
context: .
dockerfile: Dockerfile
image: llm-translator:local
init: true
restart: unless-stopped
stop_grace_period: 3m10s
ports:
- "${APP_PORT:-8000}:8000"
environment:
DATABASE_PATH: /app/data/translator.db
JWT_SECRET_FILE: /app/data/.jwt-secret
JWT_SECRET_KEY: "${JWT_SECRET_KEY:-}"
JWT_EXPIRE_HOURS: "${JWT_EXPIRE_HOURS:-24}"
LLMS_CONFIG_PATH: /app/config/llms.json
USERS_CONFIG_PATH: /app/config/users.json
LLM_TIMEOUT_SECONDS: "${LLM_TIMEOUT_SECONDS:-180}"
TRANSLATION_CHUNK_CHARS: "${TRANSLATION_CHUNK_CHARS:-1500}"
BOOTSTRAP_ADMIN_ID: "${BOOTSTRAP_ADMIN_ID:-}"
BOOTSTRAP_ADMIN_PASSWORD: "${BOOTSTRAP_ADMIN_PASSWORD:-}"
volumes:
- translator-data:/app/data
- type: bind
source: ./config/llms.json
target: /app/config/llms.json
read_only: true
- type: bind
source: ./config/users.docker.json
target: /app/config/users.json
read_only: true
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/api/health/ready', timeout=4).close()"]
interval: 30s
timeout: 5s
start_period: 20s
retries: 3
volumes:
translator-data:

1
config/users.docker.json Normal file
View file

@ -0,0 +1 @@
[]

View file

@ -1,10 +1,16 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted } from 'vue' import { onMounted, ref, watch } from 'vue'
import { useAuthStore } from './stores/auth' import { useAuthStore } from './stores/auth'
import LoginView from './views/LoginView.vue' import LoginView from './views/LoginView.vue'
import TranslatorView from './views/TranslatorView.vue' import TranslatorView from './views/TranslatorView.vue'
import AdminView from './views/AdminView.vue'
const auth = useAuthStore() const auth = useAuthStore()
const page = ref<'translator' | 'admin'>('translator')
watch(() => auth.isLoggedIn, (loggedIn) => {
if (!loggedIn) page.value = 'translator'
})
onMounted(() => { onMounted(() => {
auth.check() auth.check()
@ -13,8 +19,10 @@ onMounted(() => {
<template> <template>
<div class="min-h-screen bg-gradient-to-br from-slate-50 to-blue-50 dark:from-gray-900 dark:to-slate-800 transition-colors"> <div class="min-h-screen bg-gradient-to-br from-slate-50 to-blue-50 dark:from-gray-900 dark:to-slate-800 transition-colors">
<!-- Show Login if not authenticated, otherwise Translator -->
<LoginView v-if="!auth.isLoggedIn" /> <LoginView v-if="!auth.isLoggedIn" />
<TranslatorView v-else /> <template v-else>
<TranslatorView v-show="page === 'translator'" @open-admin="page = 'admin'" />
<AdminView v-if="page === 'admin' && auth.isAdmin" @close="page = 'translator'" />
</template>
</div> </div>
</template> </template>

View file

@ -1,7 +1,10 @@
/** 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, PhaseProgress, ProperNoun, SessionConfig, SessionResponse } from './types' import type {
LLMModel, PhaseProgress, ProperNoun, RevisionResponse, SessionConfig,
SessionResponse, SessionSummary, UserSummary,
} from './types'
export const AUTH_UNAUTHORIZED_EVENT = 'llm-translator:unauthorized' export const AUTH_UNAUTHORIZED_EVENT = 'llm-translator:unauthorized'
@ -44,11 +47,29 @@ export async function logout(): Promise<void> {
localStorage.removeItem('jwt_token') localStorage.removeItem('jwt_token')
} }
export async function me(): Promise<{ user_id: string }> { export async function me(): Promise<{ user_id: string; is_admin: boolean }> {
const res = await api.get('/auth/me') const res = await api.get('/auth/me')
return res.data return res.data
} }
// ── Administration ────────────────────────────────────
export async function listUsers(): Promise<UserSummary[]> {
const res = await api.get('/admin/users')
return res.data
}
export async function createUser(params: { id: string; password: string; is_admin: boolean }): Promise<void> {
await api.post('/admin/users', params)
}
export async function updateUser(
userId: string,
patch: { password?: string; is_admin?: boolean; is_active?: boolean },
): Promise<void> {
await api.patch(`/admin/users/${encodeURIComponent(userId)}`, patch)
}
// ── Models ─────────────────────────────────────────── // ── Models ───────────────────────────────────────────
export async function listModels(): Promise<LLMModel[]> { export async function listModels(): Promise<LLMModel[]> {
@ -68,6 +89,11 @@ export async function getSession(sessionId: string): Promise<SessionResponse> {
return res.data return res.data
} }
export async function listSessions(includeArchived = false): Promise<SessionSummary[]> {
const res = await api.get('/sessions', { params: { include_archived: includeArchived } })
return res.data
}
export async function getSessionProgress( export async function getSessionProgress(
sessionId: string, sessionId: string,
): Promise<{ progress: PhaseProgress | null }> { ): Promise<{ progress: PhaseProgress | null }> {
@ -81,12 +107,35 @@ export async function deleteSession(sessionId: string): Promise<void> {
export async function updateSession( export async function updateSession(
sessionId: string, sessionId: string,
patch: Partial<SessionConfig>, patch: Partial<SessionConfig> & { title?: string; expected_version?: number },
): 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
} }
export async function archiveSession(sessionId: string): Promise<void> {
await api.post(`/sessions/${sessionId}/archive`)
}
export async function restoreSession(sessionId: string): Promise<void> {
await api.post(`/sessions/${sessionId}/restore`)
}
export async function cloneSession(sessionId: string): Promise<{ session_id: string }> {
const res = await api.post(`/sessions/${sessionId}/clone`)
return res.data
}
export async function getRevision(sessionId: string): Promise<RevisionResponse> {
const res = await api.get(`/sessions/${sessionId}/revision`)
return res.data
}
export async function restoreRevision(sessionId: string): Promise<SessionResponse> {
const res = await api.post(`/sessions/${sessionId}/revision/restore`)
return res.data
}
// ── Phase Execution ────────────────────────────────── // ── Phase Execution ──────────────────────────────────
export async function runPhase1(sessionId: string): Promise<SessionResponse> { export async function runPhase1(sessionId: string): Promise<SessionResponse> {
@ -98,8 +147,13 @@ export async function runPhase2(
sessionId: string, sessionId: string,
proper_nouns: ProperNoun[], proper_nouns: ProperNoun[],
style: string, style: string,
expected_version: number,
): 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,
expected_version,
})
return res.data return res.data
} }

View file

@ -5,6 +5,7 @@ const props = defineProps<{
label: string label: string
value: string value: string
models: LLMModel[] models: LLMModel[]
disabled?: boolean
}>() }>()
const emit = defineEmits<{ (e: 'update:value', v: string): void }>() const emit = defineEmits<{ (e: 'update:value', v: string): void }>()
@ -18,8 +19,9 @@ function update(v: string) {
<label class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide">{{ label }}</label> <label class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide">{{ label }}</label>
<select <select
:value="value" :value="value"
:disabled="disabled"
@change="update((($event.target as HTMLSelectElement).value))" @change="update((($event.target as HTMLSelectElement).value))"
class="px-3 py-2 bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors" class="px-3 py-2 bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
> >
<option value="">-- 선택 --</option> <option value="">-- 선택 --</option>
<option v-for="m in models" :key="m.alias" :value="m.alias"> <option v-for="m in models" :key="m.alias" :value="m.alias">

View file

@ -4,6 +4,7 @@ import type { ProperNoun } from '../types'
const props = defineProps<{ const props = defineProps<{
proper_nouns: ProperNoun[] proper_nouns: ProperNoun[]
style: string style: string
disabled?: boolean
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'update:proper_nouns', v: ProperNoun[]): void (e: 'update:proper_nouns', v: ProperNoun[]): void
@ -39,9 +40,10 @@ function emitAll() {
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">문체 (수정 가능)</label> <label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">문체 (수정 가능)</label>
<input <input
v-model="styleText" v-model="styleText"
:disabled="disabled"
@change="emitAll()" @change="emitAll()"
type="text" type="text"
class="w-full px-3 py-2 border border-gray-200 dark:border-gray-600 rounded-lg text-sm bg-white dark:bg-gray-900 text-gray-800 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-blue-500" class="w-full px-3 py-2 border border-gray-200 dark:border-gray-600 rounded-lg text-sm bg-white dark:bg-gray-900 text-gray-800 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:opacity-60"
/> />
</div> </div>
</div> </div>
@ -65,6 +67,7 @@ function emitAll() {
<td class="py-1 px-2"> <td class="py-1 px-2">
<input <input
v-model="nouns[idx].final" v-model="nouns[idx].final"
:disabled="disabled"
@change="emitAll()" @change="emitAll()"
type="text" type="text"
class="w-full px-2 py-1 border border-gray-200 dark:border-gray-600 rounded text-sm bg-white dark:bg-gray-900 text-gray-800 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-blue-500" class="w-full px-2 py-1 border border-gray-200 dark:border-gray-600 rounded text-sm bg-white dark:bg-gray-900 text-gray-800 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"

View file

@ -4,12 +4,14 @@ import { AUTH_UNAUTHORIZED_EVENT, me as apiMe, logout as apiLogout } from '../ap
export const useAuthStore = defineStore('auth', () => { export const useAuthStore = defineStore('auth', () => {
const user_id = ref<string | null>(null) const user_id = ref<string | null>(null)
const isAdmin = ref(false)
const loaded = ref(false) const loaded = ref(false)
const isLoggedIn = computed(() => !!user_id.value && loaded.value) const isLoggedIn = computed(() => !!user_id.value && loaded.value)
function handleUnauthorized() { function handleUnauthorized() {
user_id.value = null user_id.value = null
isAdmin.value = false
loaded.value = true loaded.value = true
} }
@ -21,8 +23,10 @@ export const useAuthStore = defineStore('auth', () => {
try { try {
const data = await apiMe() const data = await apiMe()
user_id.value = data.user_id user_id.value = data.user_id
isAdmin.value = data.is_admin
} catch { } catch {
user_id.value = null user_id.value = null
isAdmin.value = false
} finally { } finally {
loaded.value = true loaded.value = true
} }
@ -31,7 +35,8 @@ export const useAuthStore = defineStore('auth', () => {
async function logout() { async function logout() {
await apiLogout() await apiLogout()
user_id.value = null user_id.value = null
isAdmin.value = false
} }
return { user_id, loaded, isLoggedIn, check, logout } return { user_id, isAdmin, loaded, isLoggedIn, check, logout }
}) })

View file

@ -1,11 +1,12 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import { isAxiosError } from 'axios' import { isAxiosError } from 'axios'
import type { HistoryEntry, LLMModel, Phase1Result, PhaseProgress, ProperNoun, SessionConfig, TranslationSessionData } from '../types' import type {
LLMModel, Phase1Result, PhaseProgress, ProperNoun, SessionConfig,
SessionResponse, SessionSummary, TranslationSessionData,
} from '../types'
import * as api from '../api' import * as api from '../api'
import { useAuthStore } from './auth'
const HISTORY_KEY = 'llm_translator_history'
const MODELS_KEY = 'llm_translator_models' const MODELS_KEY = 'llm_translator_models'
interface SavedModels { interface SavedModels {
@ -15,9 +16,10 @@ interface SavedModels {
} }
export const useTranslationStore = defineStore('translation', () => { export const useTranslationStore = defineStore('translation', () => {
const auth = useAuthStore()
// ── State ────────────────────────────────────────
const sessionId = ref<string | null>(null) const sessionId = ref<string | null>(null)
const sessionTitle = ref('')
const sessionVersion = ref(0)
const archivedAt = ref<string | null>(null)
const sourceText = ref('') const sourceText = ref('')
const sourceLanguage = ref('auto') const sourceLanguage = ref('auto')
const targetLanguage = ref('한국어') const targetLanguage = ref('한국어')
@ -25,37 +27,41 @@ export const useTranslationStore = defineStore('translation', () => {
const modelPhase3 = ref('') const modelPhase3 = ref('')
const modelPhase4 = ref('') const modelPhase4 = ref('')
// Phase results
const phase1Result = ref<Phase1Result | 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('')
const phase4Result = ref('') const phase4Result = ref('')
// UI state
const loading = ref(false) const loading = ref(false)
const sessionLoading = ref(false)
const historyLoading = ref(false)
const saving = ref(false)
const currentPhaseLoading = ref<number | null>(null) const currentPhaseLoading = ref<number | null>(null)
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<SessionSummary[]>([])
const historySnapshot = ref(false)
const progress = ref<PhaseProgress | null>(null) const progress = ref<PhaseProgress | null>(null)
let hydrating = false
let saveTimer: ReturnType<typeof setTimeout> | null = null
let phase2SaveTimer: ReturnType<typeof setTimeout> | null = null
let phase2EditGeneration = 0
let phase2SavePromise: Promise<void> | null = null
let editGeneration = 0
let progressTimer: ReturnType<typeof setInterval> | null = null let progressTimer: ReturnType<typeof setInterval> | null = null
let progressRequestPending = false let progressRequestPending = false
// ── Computed ─────────────────────────────────────
const phase1Done = computed(() => !!phase1Result.value) const phase1Done = computed(() => !!phase1Result.value)
const phase2Ready = computed(() => phase1Done.value && !historySnapshot.value) const editable = computed(() => !archivedAt.value && !loading.value && !sessionLoading.value)
const phase3Ready = computed(() => phase1Done.value && !historySnapshot.value) const phase2Ready = computed(() => phase1Done.value && editable.value)
const phase4Ready = computed(() => !!phase3Result.value && !historySnapshot.value) const phase3Ready = computed(() => phase1Done.value && editable.value)
const phase4Ready = computed(() => !!phase3Result.value && editable.value)
// ── Actions ────────────────────────────────────── function getErrorMessage(caught: unknown, fallback: string): string {
if (isAxiosError<{ detail?: string }>(caught)) {
function getErrorMessage(error: unknown, fallback: string): string { return caught.response?.data?.detail || caught.message || fallback
if (isAxiosError<{ detail?: string }>(error)) {
return error.response?.data?.detail || error.message || fallback
} }
return error instanceof Error ? error.message : fallback return caught instanceof Error ? caught.message : fallback
} }
function loadSavedModels(): SavedModels { function loadSavedModels(): SavedModels {
@ -81,13 +87,21 @@ export const useTranslationStore = defineStore('translation', () => {
async function loadModels() { async function loadModels() {
try { try {
models.value = await api.listModels() models.value = await api.listModels()
} catch (error: unknown) { } catch (caught: unknown) {
console.error('Failed to load models:', error) error.value = getErrorMessage(caught, '모델 목록을 불러오지 못했습니다')
} }
} }
function newSession() { function newSession() {
if (saveTimer) clearTimeout(saveTimer)
if (phase2SaveTimer) clearTimeout(phase2SaveTimer)
editGeneration += 1
phase2EditGeneration += 1
hydrating = true
sessionId.value = null sessionId.value = null
sessionTitle.value = ''
sessionVersion.value = 0
archivedAt.value = null
sourceText.value = '' sourceText.value = ''
sourceLanguage.value = 'auto' sourceLanguage.value = 'auto'
targetLanguage.value = '한국어' targetLanguage.value = '한국어'
@ -96,53 +110,22 @@ export const useTranslationStore = defineStore('translation', () => {
phase2Style.value = '' phase2Style.value = ''
phase3Result.value = '' phase3Result.value = ''
phase4Result.value = '' phase4Result.value = ''
progress.value = null
error.value = null error.value = null
historySnapshot.value = false const saved = loadSavedModels()
const savedModels = loadSavedModels()
const defaultModel = models.value[0]?.alias || '' const defaultModel = models.value[0]?.alias || ''
modelPhase1.value = validModel(savedModels.p1, defaultModel) modelPhase1.value = validModel(saved.p1, defaultModel)
modelPhase3.value = validModel(savedModels.p3, defaultModel) modelPhase3.value = validModel(saved.p3, defaultModel)
modelPhase4.value = validModel(savedModels.p4, models.value[1]?.alias || defaultModel) modelPhase4.value = validModel(saved.p4, models.value[1]?.alias || defaultModel)
hydrating = false
} }
function saveModelSelections() { function saveModelSelections() {
try { localStorage.setItem(MODELS_KEY, JSON.stringify({
localStorage.setItem(MODELS_KEY, JSON.stringify({ p1: modelPhase1.value,
p1: modelPhase1.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() {
if (!sessionId.value) {
const res = await api.createSession(getSessionParams())
sessionId.value = res.session_id
}
} }
function getSessionParams(): SessionConfig { function getSessionParams(): SessionConfig {
@ -157,30 +140,223 @@ export const useTranslationStore = defineStore('translation', () => {
} }
} }
function hydrate(response: SessionResponse) {
hydrating = true
const data = response.data
sessionId.value = response.session_id
sessionTitle.value = response.title
sessionVersion.value = response.version
archivedAt.value = response.archived_at
sourceText.value = data.source_text
sourceLanguage.value = data.source_language
targetLanguage.value = data.target_language
const defaultModel = models.value[0]?.alias || ''
modelPhase1.value = validModel(data.model_phase1, defaultModel)
modelPhase3.value = validModel(data.model_phase3, defaultModel)
modelPhase4.value = validModel(data.model_phase4, models.value[1]?.alias || defaultModel)
phase1Result.value = data.phase1_result
phase2ProperNouns.value = data.phase2_proper_nouns || []
phase2Style.value = data.phase2_style || ''
phase3Result.value = data.phase3_result || ''
phase4Result.value = data.phase4_result || ''
progress.value = data.progress || null
hydrating = false
saveModelSelections()
}
async function loadHistory() {
historyLoading.value = true
try {
history.value = await api.listSessions(true)
} catch (caught: unknown) {
error.value = getErrorMessage(caught, '번역 기록을 불러오지 못했습니다')
} finally {
historyLoading.value = false
}
}
async function openSession(id: string) {
if (saveTimer) clearTimeout(saveTimer)
if (phase2SaveTimer) clearTimeout(phase2SaveTimer)
editGeneration += 1
phase2EditGeneration += 1
sessionLoading.value = true
error.value = null
try {
hydrate(await api.getSession(id))
} catch (caught: unknown) {
error.value = getErrorMessage(caught, '세션을 불러오지 못했습니다')
} finally {
sessionLoading.value = false
}
}
function scheduleAutoSave() {
if (hydrating || loading.value || sessionLoading.value || archivedAt.value) return
editGeneration += 1
if (saveTimer) clearTimeout(saveTimer)
saveTimer = setTimeout(() => void flushAutoSave(), 650)
}
async function flushAutoSave() {
saveTimer = null
if (archivedAt.value || hydrating || loading.value) return
if (saving.value) {
saveTimer = setTimeout(() => void flushAutoSave(), 250)
return
}
const generation = editGeneration
saving.value = true
try {
if (!sessionId.value) {
const created = await api.createSession(getSessionParams())
if (generation !== editGeneration) return
hydrate(await api.getSession(created.session_id))
await loadHistory()
return
}
const savingSessionId = sessionId.value
const response = await api.updateSession(savingSessionId, {
...getSessionParams(),
expected_version: sessionVersion.value,
})
if (sessionId.value === savingSessionId) {
sessionVersion.value = response.version
sessionTitle.value = response.title
}
} catch (caught: unknown) {
error.value = getErrorMessage(caught, '변경사항을 저장하지 못했습니다')
} finally {
saving.value = false
if (generation !== editGeneration) scheduleAutoSave()
else void loadHistory()
}
}
watch([modelPhase1, modelPhase3, modelPhase4], saveModelSelections)
watch([sourceText, sourceLanguage, targetLanguage, modelPhase1], () => {
if (hydrating) return
phase1Result.value = null
phase2ProperNouns.value = []
phase2Style.value = ''
phase3Result.value = ''
phase4Result.value = ''
scheduleAutoSave()
}, { flush: 'sync' })
watch(modelPhase3, () => {
if (hydrating) return
phase3Result.value = ''
phase4Result.value = ''
scheduleAutoSave()
}, { flush: 'sync' })
watch(modelPhase4, () => {
if (hydrating) return
phase4Result.value = ''
scheduleAutoSave()
}, { flush: 'sync' })
watch([phase2ProperNouns, phase2Style], () => {
if (hydrating) return
phase2EditGeneration += 1
phase3Result.value = ''
phase4Result.value = ''
if (sessionId.value && phase1Result.value && !loading.value && !archivedAt.value) {
schedulePhase2Save()
}
}, { deep: true, flush: 'sync' })
function schedulePhase2Save(delay = 650) {
if (phase2SaveTimer) clearTimeout(phase2SaveTimer)
phase2SaveTimer = setTimeout(() => void savePhase2Draft(), delay)
}
async function savePhase2Draft() {
phase2SaveTimer = null
if (!sessionId.value || !phase1Result.value || loading.value || archivedAt.value) return
if (phase2SavePromise) {
await phase2SavePromise
if (sessionId.value && phase1Result.value && !loading.value && !archivedAt.value) {
schedulePhase2Save(100)
}
return
}
const requestSessionId = sessionId.value
const requestGeneration = phase2EditGeneration
const nouns = phase2ProperNouns.value.map(noun => ({
...noun,
final: noun.final || noun.suggested,
}))
const style = phase2Style.value
phase2SavePromise = (async () => {
const response = await api.runPhase2(
requestSessionId,
nouns,
style,
sessionVersion.value,
)
if (sessionId.value !== requestSessionId) return
if (phase2EditGeneration === requestGeneration) {
hydrate(response)
} else {
sessionVersion.value = response.version
sessionTitle.value = response.title
}
})()
try {
await phase2SavePromise
await loadHistory()
} catch (caught: unknown) {
error.value = getErrorMessage(caught, '고유명사와 문체를 저장하지 못했습니다')
} finally {
phase2SavePromise = null
if (
sessionId.value === requestSessionId
&& phase2EditGeneration !== requestGeneration
&& !loading.value
) {
schedulePhase2Save(100)
}
}
}
async function ensureSession() {
if (sessionId.value) return
const created = await api.createSession(getSessionParams())
hydrate(await api.getSession(created.session_id))
await loadHistory()
}
async function syncToServer() { async function syncToServer() {
if (!sessionId.value) return if (!sessionId.value || archivedAt.value) return
await api.updateSession(sessionId.value, getSessionParams()) if (saveTimer) clearTimeout(saveTimer)
saveTimer = null
const response = await api.updateSession(sessionId.value, {
...getSessionParams(),
expected_version: sessionVersion.value,
})
sessionVersion.value = response.version
sessionTitle.value = response.title
} }
async function submitPhase2() { async function submitPhase2() {
if (!sessionId.value) throw new Error('세션 생성 실패') if (!sessionId.value) throw new Error('세션 생성 실패')
const res = await api.runPhase2( if (phase2SaveTimer) clearTimeout(phase2SaveTimer)
phase2SaveTimer = null
if (phase2SavePromise) await phase2SavePromise
hydrate(await api.runPhase2(
sessionId.value, sessionId.value,
phase2ProperNouns.value.map(pn => ({ ...pn, final: pn.final || pn.suggested })), phase2ProperNouns.value.map(noun => ({ ...noun, final: noun.final || noun.suggested })),
phase2Style.value, phase2Style.value,
) sessionVersion.value,
phase2ProperNouns.value = res.data.phase2_proper_nouns || [] ))
phase2Style.value = res.data.phase2_style || ''
} }
async function pollProgress() { async function pollProgress() {
if (!sessionId.value || progressRequestPending) return if (!sessionId.value || progressRequestPending) return
progressRequestPending = true progressRequestPending = true
try { try {
const response = await api.getSessionProgress(sessionId.value) progress.value = (await api.getSessionProgress(sessionId.value)).progress
progress.value = response.progress
} catch { } catch {
// The phase request handles user-visible errors; polling is best-effort only. // Phase requests surface errors; progress polling is best-effort.
} finally { } finally {
progressRequestPending = false progressRequestPending = false
} }
@ -194,42 +370,27 @@ export const useTranslationStore = defineStore('translation', () => {
} }
function stopProgressPolling() { function stopProgressPolling() {
if (progressTimer !== null) { if (progressTimer !== null) clearInterval(progressTimer)
clearInterval(progressTimer) progressTimer = null
progressTimer = null
}
} }
// ── Phase Execution ──────────────────────────────
async function executePhase1() { async function executePhase1() {
if (!sourceText.value.trim()) { if (!sourceText.value.trim() || !modelPhase1.value) {
error.value = '원문을 입력해주세요' error.value = !sourceText.value.trim() ? '원문을 입력해주세요' : 'Phase 1 LLM 모델을 선택하세요'
return return
} }
if (!modelPhase1.value) {
error.value = 'Phase 1 LLM 모델을 선택하세요'
return
}
loading.value = true loading.value = true
currentPhaseLoading.value = 1 currentPhaseLoading.value = 1
error.value = null error.value = null
phase3Result.value = ''
phase4Result.value = ''
try { try {
await ensureSession() await ensureSession()
if (!sessionId.value) throw new Error('세션 생성 실패')
await syncToServer() await syncToServer()
if (!sessionId.value) throw new Error('세션 생성 실패')
startProgressPolling() startProgressPolling()
const res = await api.runPhase1(sessionId.value) hydrate(await api.runPhase1(sessionId.value))
phase1Result.value = res.data.phase1_result await loadHistory()
phase2ProperNouns.value = res.data.phase2_proper_nouns || [] } catch (caught: unknown) {
phase2Style.value = res.data.phase2_style || '' error.value = getErrorMessage(caught, 'Phase 1 실행 실패')
historySnapshot.value = false
saveToHistory()
} catch (caughtError: unknown) {
error.value = getErrorMessage(caughtError, 'Phase 1 실행 실패')
} finally { } finally {
stopProgressPolling() stopProgressPolling()
loading.value = false loading.value = false
@ -238,20 +399,15 @@ export const useTranslationStore = defineStore('translation', () => {
} }
async function executePhase2() { async function executePhase2() {
// Phase 2 is a user confirmation step — we just save the edited proper nouns and style
if (!sessionId.value) return if (!sessionId.value) return
loading.value = true loading.value = true
currentPhaseLoading.value = 2 currentPhaseLoading.value = 2
error.value = null error.value = null
phase3Result.value = ''
phase4Result.value = ''
try { try {
await submitPhase2() await submitPhase2()
saveToHistory() await loadHistory()
} catch (caughtError: unknown) { } catch (caught: unknown) {
error.value = getErrorMessage(caughtError, 'Phase 2 확인 실패') error.value = getErrorMessage(caught, 'Phase 2 확인 실패')
} finally { } finally {
loading.value = false loading.value = false
currentPhaseLoading.value = null currentPhaseLoading.value = null
@ -263,29 +419,19 @@ export const useTranslationStore = defineStore('translation', () => {
error.value = '먼저 Phase 1을 실행하세요' error.value = '먼저 Phase 1을 실행하세요'
return return
} }
if (!modelPhase3.value && !modelPhase1.value) {
error.value = 'Phase 3 LLM 모델을 선택하세요'
return
}
loading.value = true loading.value = true
currentPhaseLoading.value = 3 currentPhaseLoading.value = 3
error.value = null error.value = null
phase3Result.value = ''
phase4Result.value = ''
try { try {
await ensureSession() await ensureSession()
if (!sessionId.value) throw new Error('세션 생성 실패')
await syncToServer() await syncToServer()
await submitPhase2() await submitPhase2()
saveToHistory() if (!sessionId.value) throw new Error('세션 생성 실패')
startProgressPolling() startProgressPolling()
const res = await api.runPhase3(sessionId.value) hydrate(await api.runPhase3(sessionId.value))
phase3Result.value = res.data.phase3_result || '' await loadHistory()
saveToHistory() } catch (caught: unknown) {
} catch (caughtError: unknown) { error.value = getErrorMessage(caught, 'Phase 3 실행 실패')
error.value = getErrorMessage(caughtError, 'Phase 3 실행 실패')
} finally { } finally {
stopProgressPolling() stopProgressPolling()
loading.value = false loading.value = false
@ -298,21 +444,18 @@ export const useTranslationStore = defineStore('translation', () => {
error.value = '먼저 Phase 3을 실행하세요' error.value = '먼저 Phase 3을 실행하세요'
return return
} }
loading.value = true loading.value = true
currentPhaseLoading.value = 4 currentPhaseLoading.value = 4
error.value = null error.value = null
try { try {
await ensureSession() await ensureSession()
if (!sessionId.value) throw new Error('세션 생성 실패')
await syncToServer() await syncToServer()
if (!sessionId.value) throw new Error('세션 생성 실패')
startProgressPolling() startProgressPolling()
const res = await api.runPhase4(sessionId.value) hydrate(await api.runPhase4(sessionId.value))
phase4Result.value = res.data.phase4_result || '' await loadHistory()
saveToHistory() } catch (caught: unknown) {
} catch (caughtError: unknown) { error.value = getErrorMessage(caught, 'Phase 4 실행 실패')
error.value = getErrorMessage(caughtError, 'Phase 4 실행 실패')
} finally { } finally {
stopProgressPolling() stopProgressPolling()
loading.value = false loading.value = false
@ -320,129 +463,75 @@ export const useTranslationStore = defineStore('translation', () => {
} }
} }
// ── History (localStorage) ─────────────────────── async function archive(id: string, shouldArchive: boolean) {
function saveToHistory() {
const entry: HistoryEntry = {
id: sessionId.value || Date.now().toString(),
source_preview: sourceText.value.slice(0, 100),
target_language: targetLanguage.value,
timestamp: Date.now(),
data: getSessionData(),
}
// Avoid duplicates
const idx = history.value.findIndex(h => h.id === entry.id)
if (idx >= 0) {
history.value[idx] = entry
} else {
history.value.unshift(entry)
}
// Keep max 50 entries
if (history.value.length > 50) history.value.pop()
try { try {
localStorage.setItem(historyStorageKey(), JSON.stringify(history.value)) if (shouldArchive) await api.archiveSession(id)
} catch (error) { else await api.restoreSession(id)
console.error('Failed to save translation history:', error) if (sessionId.value === id) hydrate(await api.getSession(id))
await loadHistory()
} catch (caught: unknown) {
error.value = getErrorMessage(caught, shouldArchive ? '보관하지 못했습니다' : '복원하지 못했습니다')
} }
} }
function historyStorageKey(): string { async function clone(id: string) {
return `${HISTORY_KEY}:${auth.user_id || 'anonymous'}`
}
function loadHistory() {
try { try {
const key = historyStorageKey() const cloned = await api.cloneSession(id)
const raw = localStorage.getItem(key) || '[]' await loadHistory()
history.value = JSON.parse(raw) as HistoryEntry[] await openSession(cloned.session_id)
localStorage.removeItem(HISTORY_KEY) } catch (caught: unknown) {
} catch { error.value = getErrorMessage(caught, '세션을 복제하지 못했습니다')
history.value = []
} }
} }
function restoreFromHistory(entry: HistoryEntry) { async function remove(id: string) {
sessionId.value = null try {
sourceText.value = entry.data.source_text await api.deleteSession(id)
sourceLanguage.value = entry.data.source_language if (sessionId.value === id) newSession()
targetLanguage.value = entry.data.target_language await loadHistory()
const defaultModel = models.value[0]?.alias || '' } catch (caught: unknown) {
modelPhase1.value = validModel(entry.data.model_phase1, defaultModel) error.value = getErrorMessage(caught, '세션을 삭제하지 못했습니다')
modelPhase3.value = validModel(entry.data.model_phase3, defaultModel)
modelPhase4.value = validModel(entry.data.model_phase4, models.value[1]?.alias || defaultModel)
phase1Result.value = entry.data.phase1_result
phase2ProperNouns.value = entry.data.phase2_proper_nouns || []
phase2Style.value = entry.data.phase2_style || ''
phase3Result.value = entry.data.phase3_result || ''
phase4Result.value = entry.data.phase4_result || ''
historySnapshot.value = true
// Save model selections for future sessions
saveModelSelections()
}
function getSessionData(): TranslationSessionData {
return {
source_text: sourceText.value,
source_language: sourceLanguage.value,
target_language: targetLanguage.value,
model_phase1: modelPhase1.value,
model_phase2: '',
model_phase3: modelPhase3.value,
model_phase4: modelPhase4.value,
phase1_result: phase1Result.value || null,
phase2_proper_nouns: phase2ProperNouns.value,
phase2_style: phase2Style.value,
phase3_result: phase3Result.value,
phase4_result: phase4Result.value,
} }
} }
// ── Export ──────────────────────────────────────── async function restorePreviousRevision() {
if (!sessionId.value) return
try {
await api.getRevision(sessionId.value)
hydrate(await api.restoreRevision(sessionId.value))
await loadHistory()
} catch (caught: unknown) {
error.value = getErrorMessage(caught, '복원할 이전 버전이 없습니다')
}
}
function copyToClipboard(text: string) { function copyToClipboard(text: string) {
navigator.clipboard.writeText(text).catch(() => { navigator.clipboard.writeText(text).catch(() => { error.value = '클립보드 복사에 실패했습니다' })
error.value = '클립보드 복사에 실패했습니다'
})
} }
function downloadTxt(filename: string, content: string): void { function downloadTxt(filename: string, content: string) {
const blob = new Blob([content], { type: 'text/plain; charset=utf-8' }) const blob = new Blob([content], { type: 'text/plain; charset=utf-8' })
const url = URL.createObjectURL(blob) const url = URL.createObjectURL(blob)
const a = document.createElement('a') const link = document.createElement('a')
a.href = url link.href = url
a.download = filename link.download = filename
a.click() link.click()
URL.revokeObjectURL(url) URL.revokeObjectURL(url)
} }
function downloadMd(filename: string, content: string): void { function downloadMd(filename: string, content: string) {
// Add simple markdown formatting — frontmatter with metadata const markdown = `---\nsource_language: ${sourceLanguage.value}\ntarget_language: ${targetLanguage.value}\ntimestamp: ${new Date().toISOString()}\n---\n\n${content}`
const md = `--- downloadTxt(filename.replace('.txt', '.md'), markdown)
source_language: ${sourceLanguage.value}
target_language: ${targetLanguage.value}
timestamp: ${new Date().toISOString()}
---\n\n${content}`
downloadTxt(filename.replace('.txt', '.md'), md)
} }
return { return {
// State sessionId, sessionTitle, sessionVersion, archivedAt, sourceText, sourceLanguage, targetLanguage,
sessionId, sourceText, sourceLanguage, targetLanguage, modelPhase1, modelPhase3, modelPhase4, phase1Result, phase2ProperNouns, phase2Style,
modelPhase1, modelPhase3, modelPhase4, phase3Result, phase4Result, loading, sessionLoading, historyLoading, saving,
phase1Result, phase2ProperNouns, phase2Style, currentPhaseLoading, error, models, history, progress, phase1Done, phase2Ready,
phase3Result, phase4Result, phase3Ready, phase4Ready, editable, loadModels, newSession, ensureSession, syncToServer,
loading, currentPhaseLoading, error, models, history, progress, loadHistory, openSession, executePhase1, executePhase2, executePhase3, executePhase4,
archive, clone, remove, restorePreviousRevision, copyToClipboard, downloadTxt, downloadMd,
// Computed getSessionParams,
phase1Done, phase2Ready, phase3Ready, phase4Ready,
// Actions
loadModels, newSession, saveModelSelections, ensureSession, syncToServer,
executePhase1, executePhase2, executePhase3, executePhase4,
saveToHistory, loadHistory, restoreFromHistory,
copyToClipboard, downloadTxt, downloadMd, getSessionData,
} }
}) })

View file

@ -40,6 +40,7 @@ export interface TranslationSessionData {
phase1_chunks?: Array<{ source_text: string; translated: string }> phase1_chunks?: Array<{ source_text: string; translated: string }>
phase2_proper_nouns: ProperNoun[] phase2_proper_nouns: ProperNoun[]
phase2_style: string phase2_style: string
phase2_confirmed: boolean
phase3_result: string phase3_result: string
phase3_chunks?: string[] phase3_chunks?: string[]
phase4_result: string phase4_result: string
@ -60,13 +61,38 @@ export type SessionConfig = Pick<
export interface SessionResponse { export interface SessionResponse {
session_id: string session_id: string
data: TranslationSessionData data: TranslationSessionData
title: string
status: string
current_phase: number
version: number
created_at: string
updated_at: string
archived_at: string | null
} }
/** A saved translation history entry (localStorage) */ export interface SessionSummary {
export interface HistoryEntry { session_id: string
id: string title: string
source_preview: string source_preview: string
target_language: string target_language: string
timestamp: number status: string
data: TranslationSessionData current_phase: number
version: number
created_at: string
updated_at: string
archived_at: string | null
}
export interface RevisionResponse {
data: TranslationSessionData
reason: string
created_at: string
}
export interface UserSummary {
id: string
is_admin: boolean
is_active: boolean
created_at: string
updated_at: string
} }

View file

@ -0,0 +1,126 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { isAxiosError } from 'axios'
import * as api from '../api'
import type { UserSummary } from '../types'
import { useAuthStore } from '../stores/auth'
import { useThemeStore } from '../stores/theme'
defineEmits<{ (event: 'close'): void }>()
const auth = useAuthStore()
const theme = useThemeStore()
const users = ref<UserSummary[]>([])
const loading = ref(false)
const error = ref('')
const success = ref('')
const form = reactive({ id: '', password: '', is_admin: false })
const passwords = reactive<Record<string, string>>({})
function message(caught: unknown, fallback: string) {
return isAxiosError<{ detail?: string }>(caught)
? caught.response?.data?.detail || fallback
: fallback
}
async function loadUsers() {
loading.value = true
error.value = ''
try {
users.value = await api.listUsers()
} catch (caught: unknown) {
error.value = message(caught, '사용자 목록을 불러오지 못했습니다')
} finally {
loading.value = false
}
}
async function addUser() {
error.value = ''
success.value = ''
try {
await api.createUser(form)
success.value = `${form.id} 사용자를 만들었습니다.`
form.id = ''
form.password = ''
form.is_admin = false
await loadUsers()
} catch (caught: unknown) {
error.value = message(caught, '사용자를 만들지 못했습니다')
}
}
async function update(user: UserSummary, patch: { password?: string; is_admin?: boolean; is_active?: boolean }) {
error.value = ''
success.value = ''
try {
await api.updateUser(user.id, patch)
success.value = `${user.id} 계정을 변경했습니다.`
passwords[user.id] = ''
await loadUsers()
} catch (caught: unknown) {
error.value = message(caught, '사용자를 변경하지 못했습니다')
}
}
onMounted(loadUsers)
</script>
<template>
<div class="min-h-screen text-gray-800 dark:text-gray-100">
<header class="sticky top-0 z-10 border-b border-gray-200 bg-white/90 shadow-sm backdrop-blur dark:border-gray-700 dark:bg-gray-800/90">
<div class="mx-auto flex max-w-6xl flex-wrap items-center justify-between gap-3 px-4 py-3">
<div class="flex items-center gap-3">
<button @click="$emit('close')" class="rounded-lg bg-gray-100 px-3 py-1.5 text-sm hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600">번역기로</button>
<h1 class="text-lg font-bold">사용자 관리</h1>
</div>
<div class="flex items-center gap-3">
<button @click="theme.toggle()" class="rounded-lg bg-gray-100 px-3 py-1.5 text-sm dark:bg-gray-700">{{ theme.isDark ? '라이트' : '다크' }}</button>
<span class="text-sm text-gray-500 dark:text-gray-300">{{ auth.user_id }}</span>
<button @click="auth.logout()" class="rounded-lg bg-red-50 px-3 py-1.5 text-sm text-red-600 dark:bg-red-900/30">로그아웃</button>
</div>
</div>
</header>
<main class="mx-auto max-w-6xl space-y-6 px-4 py-6">
<section class="rounded-xl border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-800">
<h2 class="mb-4 font-semibold"> 사용자</h2>
<form class="grid gap-3 md:grid-cols-[1fr_1fr_auto_auto]" @submit.prevent="addUser">
<input v-model.trim="form.id" required maxlength="50" pattern="[A-Za-z0-9_.-]+" placeholder="사용자 ID" class="rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-900" />
<input v-model="form.password" required minlength="8" type="password" autocomplete="new-password" placeholder="비밀번호 (8자 이상)" class="rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-900" />
<label class="flex items-center gap-2 text-sm"><input v-model="form.is_admin" type="checkbox" /> 관리자</label>
<button :disabled="loading" class="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white disabled:opacity-50">추가</button>
</form>
</section>
<div v-if="error" class="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700 dark:border-red-800 dark:bg-red-900/20 dark:text-red-300">{{ error }}</div>
<div v-if="success" class="rounded-lg border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-700 dark:border-emerald-800 dark:bg-emerald-900/20 dark:text-emerald-300">{{ success }}</div>
<section class="overflow-hidden rounded-xl border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800">
<div class="flex items-center justify-between border-b border-gray-200 px-5 py-4 dark:border-gray-700">
<h2 class="font-semibold">사용자 {{ users.length }}</h2>
<button @click="loadUsers" :disabled="loading" class="text-sm text-blue-600 disabled:opacity-50 dark:text-blue-400">새로고침</button>
</div>
<div v-if="loading && !users.length" class="p-8 text-center text-sm text-gray-400">불러오는 중...</div>
<div v-else class="divide-y divide-gray-100 dark:divide-gray-700">
<article v-for="user in users" :key="user.id" class="grid gap-4 p-5 lg:grid-cols-[1fr_auto_auto_2fr] lg:items-center">
<div>
<div class="font-medium">{{ user.id }} <span v-if="user.id === auth.user_id" class="text-xs text-blue-500"> 계정</span></div>
<div class="mt-1 text-xs text-gray-400">생성 {{ new Date(user.created_at).toLocaleDateString('ko-KR') }}</div>
</div>
<label class="flex items-center gap-2 text-sm">
<input type="checkbox" :checked="user.is_admin" :disabled="user.id === auth.user_id" @change="update(user, { is_admin: ($event.target as HTMLInputElement).checked })" /> 관리자
</label>
<label class="flex items-center gap-2 text-sm">
<input type="checkbox" :checked="user.is_active" :disabled="user.id === auth.user_id" @change="update(user, { is_active: ($event.target as HTMLInputElement).checked })" /> 활성
</label>
<form class="flex gap-2" @submit.prevent="update(user, { password: passwords[user.id] })">
<input v-model="passwords[user.id]" required minlength="8" type="password" autocomplete="new-password" placeholder="새 비밀번호" class="min-w-0 flex-1 rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-900" />
<button class="whitespace-nowrap rounded-lg bg-gray-100 px-3 py-2 text-sm hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600">재설정</button>
</form>
</article>
</div>
</section>
</main>
</div>
</template>

View file

@ -11,11 +11,12 @@ const store = useTranslationStore()
const auth = useAuthStore() const auth = useAuthStore()
const theme = useThemeStore() const theme = useThemeStore()
const showHistorySidebar = ref(false) const showHistorySidebar = ref(false)
defineEmits<{ (event: 'open-admin'): void }>()
onMounted(async () => { onMounted(async () => {
await store.loadModels() await store.loadModels()
store.newSession() store.newSession()
store.loadHistory() await store.loadHistory()
}) })
// Language options // Language options
@ -56,13 +57,26 @@ function handleNewTranslation() {
} }
store.newSession() store.newSession()
} }
async function openHistorySession(id: string) {
await store.openSession(id)
showHistorySidebar.value = false
}
async function removeSession(id: string) {
if (confirm('이 번역 기록을 영구 삭제하시겠습니까?')) await store.remove(id)
}
async function restoreRevision() {
if (confirm('현재 내용을 직전 저장 버전으로 되돌리시겠습니까?')) await store.restorePreviousRevision()
}
</script> </script>
<template> <template>
<div class="min-h-screen"> <div class="min-h-screen">
<!-- Top Bar --> <!-- Top Bar -->
<header class="bg-white/90 dark:bg-gray-800/90 border-b border-gray-200 dark:border-gray-700 sticky top-0 z-10 shadow-sm backdrop-blur"> <header class="bg-white/90 dark:bg-gray-800/90 border-b border-gray-200 dark:border-gray-700 sticky top-0 z-10 shadow-sm backdrop-blur">
<div class="max-w-7xl mx-auto px-4 py-3 flex items-center justify-between"> <div class="max-w-7xl mx-auto px-4 py-3 flex flex-wrap items-center justify-between gap-3">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<div class="w-8 h-8 rounded-full bg-blue-600 flex items-center justify-center"> <div class="w-8 h-8 rounded-full bg-blue-600 flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
@ -72,7 +86,7 @@ function handleNewTranslation() {
<h1 class="text-lg font-bold text-gray-800 dark:text-gray-100">LLM 번역기</h1> <h1 class="text-lg font-bold text-gray-800 dark:text-gray-100">LLM 번역기</h1>
</div> </div>
<div class="flex items-center gap-3"> <div class="flex flex-wrap items-center justify-end gap-2">
<!-- Dark Mode Toggle --> <!-- Dark Mode Toggle -->
<button <button
@click="theme.toggle()" @click="theme.toggle()"
@ -84,13 +98,21 @@ function handleNewTranslation() {
<!-- History Button --> <!-- History Button -->
<button <button
@click="showHistorySidebar = true" @click="showHistorySidebar = true; store.loadHistory()"
class="px-3 py-1.5 text-sm bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors flex items-center gap-1.5" class="px-3 py-1.5 text-sm bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors flex items-center gap-1.5"
title="히스토리" title="히스토리"
> >
🕐 히스토리 ({{ store.history.length }}) 🕐 히스토리 ({{ store.history.length }})
</button> </button>
<button
v-if="auth.isAdmin"
@click="$emit('open-admin')"
class="px-3 py-1.5 text-sm bg-blue-50 dark:bg-blue-900/30 hover:bg-blue-100 dark:hover:bg-blue-900/50 text-blue-700 dark:text-blue-300 rounded-lg transition-colors"
>
사용자 관리
</button>
<!-- User Info & Logout --> <!-- User Info & Logout -->
<span class="text-sm text-gray-600 dark:text-gray-300">{{ auth.user_id }}</span> <span class="text-sm text-gray-600 dark:text-gray-300">{{ auth.user_id }}</span>
<button @click="auth.logout()" class="px-3 py-1.5 text-sm bg-red-50 dark:bg-red-900/30 hover:bg-red-100 dark:hover:bg-red-900/50 text-red-600 rounded-lg transition-colors"> <button @click="auth.logout()" class="px-3 py-1.5 text-sm bg-red-50 dark:bg-red-900/30 hover:bg-red-100 dark:hover:bg-red-900/50 text-red-600 rounded-lg transition-colors">
@ -102,18 +124,24 @@ function handleNewTranslation() {
<!-- Main Content --> <!-- Main Content -->
<main class="max-w-7xl mx-auto px-4 py-6 space-y-6"> <main class="max-w-7xl mx-auto px-4 py-6 space-y-6">
<div v-if="store.archivedAt" class="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800 dark:border-amber-800 dark:bg-amber-900/20 dark:text-amber-300">
<span>보관된 번역입니다. 내용을 편집하거나 단계를 실행하려면 먼저 복원하세요.</span>
<button @click="store.archive(store.sessionId!, false)" class="rounded-lg bg-amber-100 px-3 py-1.5 font-medium hover:bg-amber-200 dark:bg-amber-900/50">복원</button>
</div>
<!-- Model Selection Row --> <!-- Model Selection Row -->
<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" :disabled="!store.editable" />
<LLMSelector label="Phase 3 — 재번역" v-model:value="store.modelPhase3" :models="store.models" /> <LLMSelector label="Phase 3 — 재번역" v-model:value="store.modelPhase3" :models="store.models" :disabled="!store.editable" />
<LLMSelector label="Phase 4 — 마무리" v-model:value="store.modelPhase4" :models="store.models" /> <LLMSelector label="Phase 4 — 마무리" v-model:value="store.modelPhase4" :models="store.models" :disabled="!store.editable" />
<!-- Language Selectors --> <!-- Language Selectors -->
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<label class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide">출발어</label> <label class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide">출발어</label>
<select <select
v-model="store.sourceLanguage" v-model="store.sourceLanguage"
:disabled="!store.editable"
class="px-3 py-2 bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors" class="px-3 py-2 bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
> >
<option v-for="lang in languages" :key="lang.value" :value="lang.value"> <option v-for="lang in languages" :key="lang.value" :value="lang.value">
@ -128,6 +156,7 @@ function handleNewTranslation() {
<label class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide">도착어</label> <label class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide">도착어</label>
<select <select
v-model="store.targetLanguage" v-model="store.targetLanguage"
:disabled="!store.editable"
class="px-3 py-2 bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors" class="px-3 py-2 bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
> >
<option v-for="lang in targetLanguages" :key="lang.value" :value="lang.value"> <option v-for="lang in targetLanguages" :key="lang.value" :value="lang.value">
@ -137,9 +166,13 @@ function handleNewTranslation() {
</div> </div>
<!-- New Translation Button --> <!-- New Translation Button -->
<button @click="handleNewTranslation()" class="px-4 py-2 text-sm bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors"> <button @click="handleNewTranslation()" :disabled="store.loading || store.saving" class="px-4 py-2 text-sm bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors disabled:opacity-50">
🔄 번역 🔄 번역
</button> </button>
<button v-if="store.sessionId && store.editable" @click="restoreRevision" class="px-4 py-2 text-sm text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors">
이전 버전 복원
</button>
<span v-if="store.saving" class="text-xs text-gray-400">저장 중...</span>
</div> </div>
</section> </section>
@ -151,9 +184,10 @@ function handleNewTranslation() {
</div> </div>
<textarea <textarea
v-model="store.sourceText" v-model="store.sourceText"
:disabled="!store.editable"
rows="10" rows="10"
placeholder="번역할 원문을 여기에 붙여넣으세요..." placeholder="번역할 원문을 여기에 붙여넣으세요..."
class="w-full px-4 py-3 border border-gray-200 dark:border-gray-600 rounded-lg text-sm leading-relaxed resize-y bg-white dark:bg-gray-900 text-gray-800 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors" class="w-full px-4 py-3 border border-gray-200 dark:border-gray-600 rounded-lg text-sm leading-relaxed resize-y bg-white dark:bg-gray-900 text-gray-800 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors disabled:opacity-70 disabled:cursor-not-allowed"
></textarea> ></textarea>
</section> </section>
@ -161,7 +195,7 @@ function handleNewTranslation() {
<section class="flex flex-wrap gap-3"> <section class="flex flex-wrap gap-3">
<button <button
@click="runPhase(1)" @click="runPhase(1)"
:disabled="store.loading || !store.sourceText.trim() || !store.modelPhase1" :disabled="store.loading || store.saving || !store.sourceText.trim() || !store.modelPhase1"
class="px-5 py-2.5 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 disabled:opacity-40 disabled:cursor-not-allowed transition-colors flex items-center gap-2" class="px-5 py-2.5 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 disabled:opacity-40 disabled:cursor-not-allowed transition-colors flex items-center gap-2"
> >
<span v-if="store.currentPhaseLoading === 1"></span> <span v-if="store.currentPhaseLoading === 1"></span>
@ -171,7 +205,7 @@ function handleNewTranslation() {
<button <button
@click="runPhase(2)" @click="runPhase(2)"
:disabled="store.loading || !store.phase2Ready" :disabled="store.loading || store.saving || !store.phase2Ready"
class="px-5 py-2.5 bg-emerald-600 text-white rounded-lg font-medium hover:bg-emerald-700 disabled:opacity-40 disabled:cursor-not-allowed transition-colors flex items-center gap-2" class="px-5 py-2.5 bg-emerald-600 text-white rounded-lg font-medium hover:bg-emerald-700 disabled:opacity-40 disabled:cursor-not-allowed transition-colors flex items-center gap-2"
> >
<span v-if="store.currentPhaseLoading === 2"></span> <span v-if="store.currentPhaseLoading === 2"></span>
@ -181,7 +215,7 @@ function handleNewTranslation() {
<button <button
@click="runPhase(3)" @click="runPhase(3)"
:disabled="store.loading || !store.phase3Ready" :disabled="store.loading || store.saving || !store.phase3Ready"
class="px-5 py-2.5 bg-purple-600 text-white rounded-lg font-medium hover:bg-purple-700 disabled:opacity-40 disabled:cursor-not-allowed transition-colors flex items-center gap-2" class="px-5 py-2.5 bg-purple-600 text-white rounded-lg font-medium hover:bg-purple-700 disabled:opacity-40 disabled:cursor-not-allowed transition-colors flex items-center gap-2"
> >
<span v-if="store.currentPhaseLoading === 3"></span> <span v-if="store.currentPhaseLoading === 3"></span>
@ -191,7 +225,7 @@ function handleNewTranslation() {
<button <button
@click="runPhase(4)" @click="runPhase(4)"
:disabled="store.loading || !store.phase4Ready" :disabled="store.loading || store.saving || !store.phase4Ready"
class="px-5 py-2.5 bg-pink-600 text-white rounded-lg font-medium hover:bg-pink-700 disabled:opacity-40 disabled:cursor-not-allowed transition-colors flex items-center gap-2" class="px-5 py-2.5 bg-pink-600 text-white rounded-lg font-medium hover:bg-pink-700 disabled:opacity-40 disabled:cursor-not-allowed transition-colors flex items-center gap-2"
> >
<span v-if="store.currentPhaseLoading === 4"></span> <span v-if="store.currentPhaseLoading === 4"></span>
@ -236,6 +270,7 @@ function handleNewTranslation() {
<ProperNounsEditor <ProperNounsEditor
:proper_nouns="store.phase2ProperNouns" :proper_nouns="store.phase2ProperNouns"
:style="store.phase2Style" :style="store.phase2Style"
:disabled="!store.editable"
@update:proper_nouns="(v) => store.phase2ProperNouns = v" @update:proper_nouns="(v) => store.phase2ProperNouns = v"
@update:style="(v) => store.phase2Style = v" @update:style="(v) => store.phase2Style = v"
/> />
@ -259,31 +294,45 @@ function handleNewTranslation() {
<!-- History Sidebar (overlay) --> <!-- History Sidebar (overlay) -->
<Transition name="slide"> <Transition name="slide">
<div v-if="showHistorySidebar" class="fixed inset-y-0 right-0 w-80 bg-white dark:bg-gray-800 shadow-2xl z-50 p-6 overflow-y-auto border-l border-gray-200 dark:border-gray-700"> <div v-if="showHistorySidebar" class="fixed inset-y-0 right-0 w-full max-w-sm bg-white dark:bg-gray-800 shadow-2xl z-50 p-6 overflow-y-auto border-l border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-bold text-gray-800 dark:text-gray-100 mb-4 flex items-center justify-between"> <h3 class="text-lg font-bold text-gray-800 dark:text-gray-100 mb-4 flex items-center justify-between">
번역 히스토리 번역 히스토리
<button @click="showHistorySidebar = false" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"></button> <button @click="showHistorySidebar = false" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"></button>
</h3> </h3>
<!-- History List --> <div class="mb-4 flex items-center justify-between text-xs text-gray-400">
<div v-if="store.history.length === 0" class="text-sm text-gray-400 italic mt-4"> <span>서버에 저장된 번역과 보관함</span>
<button @click="store.loadHistory()" :disabled="store.historyLoading" class="text-blue-500 disabled:opacity-50">새로고침</button>
</div>
<div v-if="store.historyLoading && store.history.length === 0" class="text-sm text-gray-400 italic mt-4">불러오는 중...</div>
<div v-else-if="store.history.length === 0" class="text-sm text-gray-400 italic mt-4">
아직 번역 기록이 없습니다. 아직 번역 기록이 없습니다.
</div> </div>
<ul v-else class="space-y-2"> <ul v-else class="space-y-2">
<li v-for="(entry, idx) in store.history" :key="idx"> <li v-for="entry in store.history" :key="entry.session_id" class="rounded-lg border border-gray-100 p-3 dark:border-gray-600">
<button <button
@click="store.restoreFromHistory(entry); showHistorySidebar = false" @click="openHistorySession(entry.session_id)"
class="w-full text-left p-3 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 border border-gray-100 dark:border-gray-600 transition-colors" :disabled="store.saving || store.sessionLoading"
class="w-full text-left rounded-md hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors disabled:opacity-60"
> >
<div class="text-xs text-gray-400 mb-1"> <div class="mb-1 flex items-center justify-between gap-2 text-xs text-gray-400">
{{ new Date(entry.timestamp).toLocaleString('ko-KR') }} <span>{{ new Date(entry.updated_at).toLocaleString('ko-KR') }}</span>
<span v-if="entry.archived_at" class="rounded bg-amber-100 px-1.5 py-0.5 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300">보관됨</span>
</div> </div>
<div class="text-sm font-medium text-gray-700 dark:text-gray-200 line-clamp-2"> <div class="text-sm font-semibold text-gray-700 dark:text-gray-200 line-clamp-2">{{ entry.title }}</div>
<div class="mt-1 text-xs text-gray-500 dark:text-gray-400 line-clamp-2">
{{ entry.source_preview || '(빈 원문)' }} {{ entry.source_preview || '(빈 원문)' }}
</div> </div>
<div class="text-xs text-blue-500 mt-1"> {{ entry.target_language }}</div> <div class="text-xs text-blue-500 mt-1">Phase {{ entry.current_phase }} · {{ entry.target_language }}</div>
</button> </button>
<div class="mt-3 flex flex-wrap gap-2 border-t border-gray-100 pt-2 text-xs dark:border-gray-700">
<button @click="store.clone(entry.session_id)" class="text-blue-600 hover:underline dark:text-blue-400">복제</button>
<button v-if="entry.archived_at" @click="store.archive(entry.session_id, false)" class="text-emerald-600 hover:underline dark:text-emerald-400">복원</button>
<button v-else @click="store.archive(entry.session_id, true)" class="text-amber-600 hover:underline dark:text-amber-400">보관</button>
<button @click="removeSession(entry.session_id)" class="ml-auto text-red-600 hover:underline dark:text-red-400">삭제</button>
</div>
</li> </li>
</ul> </ul>
</div> </div>

View file

@ -1,19 +1,43 @@
import json import json
import tempfile
import unittest import unittest
from pathlib import Path
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, patch
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from backend.auth import create_access_token from backend.auth import create_access_token, get_current_user, require_admin
from backend.database import Database, LastAdminError
from backend.llm_client import get_llm_configs from backend.llm_client import get_llm_configs
from backend.main import _split_text, app from backend.main import _split_text, app
from backend.models import TranslationSession from backend.models import TranslationSession
from backend.sessions import SessionConflictError, SessionStore, session_store from backend.sessions import (
SessionArchivedError,
SessionConflictError,
SessionStore,
session_store,
)
class ApiTests(unittest.TestCase): class ApiTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.temp_dir = tempfile.TemporaryDirectory()
cls.db = Database(Path(cls.temp_dir.name) / "api.db")
cls.db.create_user("admin", "unused", is_admin=True)
session_store.db = cls.db
app.dependency_overrides[get_current_user] = lambda: "admin"
@classmethod
def tearDownClass(cls):
app.dependency_overrides.clear()
cls.temp_dir.cleanup()
def setUp(self): def setUp(self):
session_store._store.clear() with self.db.connect() as connection:
connection.execute("DELETE FROM session_revisions")
connection.execute("DELETE FROM translation_sessions")
session_store._progress.clear()
token = create_access_token({"user_id": "admin"}) token = create_access_token({"user_id": "admin"})
self.client = TestClient(app, headers={"Authorization": f"Bearer {token}"}) self.client = TestClient(app, headers={"Authorization": f"Bearer {token}"})
self.model = get_llm_configs()[0].alias self.model = get_llm_configs()[0].alias
@ -35,9 +59,13 @@ class ApiTests(unittest.TestCase):
return response.json()["session_id"] return response.json()["session_id"]
def test_translation_routes_require_authentication(self): def test_translation_routes_require_authentication(self):
response = TestClient(app).post( override = app.dependency_overrides.pop(get_current_user)
"/api/translate/create", json={"source_text": "Hello"} try:
) response = TestClient(app).post(
"/api/translate/create", json={"source_text": "Hello"}
)
finally:
app.dependency_overrides[get_current_user] = override
self.assertEqual(response.status_code, 401) self.assertEqual(response.status_code, 401)
def test_progress_endpoint_returns_only_transient_progress(self): def test_progress_endpoint_returns_only_transient_progress(self):
@ -89,28 +117,25 @@ class ApiTests(unittest.TestCase):
) )
responses = [phase1, "다시 번역", "최종 번역"] responses = [phase1, "다시 번역", "최종 번역"]
with patch("backend.main.chat_complete", new=AsyncMock(side_effect=responses)): with patch("backend.main.chat_complete", new=AsyncMock(side_effect=responses)):
self.assertEqual( response = self.client.post(f"/api/translate/{session_id}/phase1")
self.client.post(f"/api/translate/{session_id}/phase1").status_code, self.assertEqual(response.status_code, 200)
200, response = self.client.post(
) f"/api/translate/{session_id}/phase2",
self.assertEqual( json={
self.client.post( "proper_nouns": [],
f"/api/translate/{session_id}/phase2", "style": "중립적",
json={"proper_nouns": [], "style": "중립적"}, "expected_version": response.json()["version"],
).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,
) )
self.assertEqual(response.status_code, 200)
response = self.client.post(f"/api/translate/{session_id}/phase3")
self.assertEqual(response.status_code, 200)
response = self.client.post(f"/api/translate/{session_id}/phase4")
self.assertEqual(response.status_code, 200)
response = self.client.patch( response = self.client.patch(
f"/api/sessions/{session_id}", json={"source_text": "Changed"} f"/api/sessions/{session_id}",
json={"source_text": "Changed", "expected_version": response.json()["version"]},
) )
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
data = response.json()["data"] data = response.json()["data"]
@ -147,6 +172,47 @@ class ApiTests(unittest.TestCase):
self.assertEqual(response.json()["data"]["phase1_result"]["translated"], "안녕하세요") self.assertEqual(response.json()["data"]["phase1_result"]["translated"], "안녕하세요")
self.assertEqual(mocked.await_count, 1) self.assertEqual(mocked.await_count, 1)
def test_phase2_rejects_stale_version(self):
session_id = self.create_session()
phase1 = json.dumps(
{
"translated": "안녕하세요",
"proper_nouns": [],
"summary": "",
"style": "",
},
ensure_ascii=False,
)
with patch("backend.main.chat_complete", new=AsyncMock(return_value=phase1)):
response = self.client.post(f"/api/translate/{session_id}/phase1")
stale_version = response.json()["version"]
session_store.update(session_id, {"phase2_style": "newer"}, "admin")
response = self.client.post(
f"/api/translate/{session_id}/phase2",
json={
"proper_nouns": [],
"style": "stale",
"expected_version": stale_version,
},
)
self.assertEqual(response.status_code, 409)
def test_archived_session_rejects_api_patch(self):
session_id = self.create_session()
response = self.client.get(f"/api/sessions/{session_id}")
version = response.json()["version"]
self.assertEqual(
self.client.post(f"/api/sessions/{session_id}/archive").status_code,
200,
)
response = self.client.patch(
f"/api/sessions/{session_id}",
json={"source_text": "changed", "expected_version": version + 1},
)
self.assertEqual(response.status_code, 409)
def test_long_phase1_is_split_into_bounded_chunks(self): def test_long_phase1_is_split_into_bounded_chunks(self):
source_text = "这是用于测试长文本分割的句子。" * 120 source_text = "这是用于测试长文本分割的句子。" * 120
expected_chunks = _split_text(source_text) expected_chunks = _split_text(source_text)
@ -170,10 +236,15 @@ class ApiTests(unittest.TestCase):
self.assertEqual(mocked.await_count, len(expected_chunks)) self.assertEqual(mocked.await_count, len(expected_chunks))
self.assertEqual(len(response.json()["data"]["phase1_chunks"]), len(expected_chunks)) self.assertEqual(len(response.json()["data"]["phase1_chunks"]), len(expected_chunks))
self.client.post( response = self.client.post(
f"/api/translate/{session_id}/phase2", f"/api/translate/{session_id}/phase2",
json={"proper_nouns": [], "style": "중립적"}, json={
"proper_nouns": [],
"style": "중립적",
"expected_version": response.json()["version"],
},
) )
self.assertEqual(response.status_code, 200)
phase3_mock = AsyncMock(side_effect=["재번역 조각"] * len(expected_chunks)) phase3_mock = AsyncMock(side_effect=["재번역 조각"] * len(expected_chunks))
with patch("backend.main.chat_complete", new=phase3_mock): with patch("backend.main.chat_complete", new=phase3_mock):
response = self.client.post(f"/api/translate/{session_id}/phase3") response = self.client.post(f"/api/translate/{session_id}/phase3")
@ -201,8 +272,18 @@ class ApiTests(unittest.TestCase):
class SessionStoreTests(unittest.TestCase): class SessionStoreTests(unittest.TestCase):
def setUp(self):
self.temp_dir = tempfile.TemporaryDirectory()
self.db = Database(Path(self.temp_dir.name) / "store.db")
self.db.create_user("alice", "unused")
self.db.create_user("bob", "unused")
self.store = SessionStore(self.db)
def tearDown(self):
self.temp_dir.cleanup()
def test_sessions_are_isolated_by_owner(self): def test_sessions_are_isolated_by_owner(self):
store = SessionStore() store = self.store
session_id = store.create(TranslationSession(source_text="secret"), "alice") session_id = store.create(TranslationSession(source_text="secret"), "alice")
self.assertIsNotNone(store.get(session_id, "alice")) self.assertIsNotNone(store.get(session_id, "alice"))
@ -212,7 +293,7 @@ class SessionStoreTests(unittest.TestCase):
self.assertEqual(store.get(session_id, "alice").source_text, "secret") self.assertEqual(store.get(session_id, "alice").source_text, "secret")
def test_stale_phase_update_is_rejected(self): def test_stale_phase_update_is_rejected(self):
store = SessionStore() store = self.store
session_id = store.create(TranslationSession(source_text="first"), "alice") session_id = store.create(TranslationSession(source_text="first"), "alice")
_, version = store.get_with_version(session_id, "alice") _, version = store.get_with_version(session_id, "alice")
store.update(session_id, {"source_text": "second"}, "alice") store.update(session_id, {"source_text": "second"}, "alice")
@ -226,7 +307,7 @@ class SessionStoreTests(unittest.TestCase):
) )
def test_progress_does_not_change_session_version(self): def test_progress_does_not_change_session_version(self):
store = SessionStore() store = self.store
session_id = store.create(TranslationSession(source_text="first"), "alice") session_id = store.create(TranslationSession(source_text="first"), "alice")
_, version = store.get_with_version(session_id, "alice") _, version = store.get_with_version(session_id, "alice")
@ -247,6 +328,135 @@ class SessionStoreTests(unittest.TestCase):
self.assertEqual(current_version, version) self.assertEqual(current_version, version)
self.assertEqual(session.progress.preview, "부분 결과") self.assertEqual(session.progress.preview, "부분 결과")
def test_session_persists_across_store_instances(self):
session_id = self.store.create(
TranslationSession(source_text="persistent text"), "alice"
)
reopened_store = SessionStore(Database(self.db.path))
reopened = reopened_store.get(session_id, "alice")
self.assertIsNotNone(reopened)
self.assertEqual(reopened.source_text, "persistent text")
def test_previous_revision_can_be_restored(self):
session_id = self.store.create(TranslationSession(source_text="first"), "alice")
self.store.update(
session_id,
{
"phase1_result": {
"translated": "첫 번역",
"proper_nouns": [],
"summary": "",
"style": "",
}
},
"alice",
current_phase=1,
)
self.store.update(
session_id,
{"source_text": "second", "phase1_result": None},
"alice",
snapshot_reason="session_edit",
current_phase=0,
)
revision = self.store.get_revision(session_id, "alice")
self.assertEqual(revision["data"]["source_text"], "first")
self.assertTrue(self.store.restore_revision(session_id, "alice"))
restored = self.store.get(session_id, "alice")
self.assertEqual(restored.source_text, "first")
self.assertEqual(restored.phase1_result.translated, "첫 번역")
def test_archive_clone_and_list_are_owner_scoped(self):
session_id = self.store.create(TranslationSession(source_text="Alice text"), "alice")
self.store.create(TranslationSession(source_text="Bob text"), "bob")
self.assertEqual(len(self.store.list("alice")), 1)
self.assertTrue(self.store.archive(session_id, "alice"))
self.assertEqual(self.store.list("alice"), [])
self.assertEqual(len(self.store.list("alice", include_archived=True)), 1)
clone_id = self.store.clone(session_id, "alice")
self.assertIsNotNone(clone_id)
self.assertIsNone(self.store.get(clone_id, "bob"))
def test_archived_session_rejects_content_updates(self):
session_id = self.store.create(TranslationSession(source_text="locked"), "alice")
_, version = self.store.get_with_version(session_id, "alice")
self.assertTrue(self.store.archive(session_id, "alice"))
with self.assertRaises(SessionArchivedError):
self.store.update(
session_id,
{"source_text": "changed"},
"alice",
expected_version=version + 1,
)
def test_clone_preserves_completed_phase_metadata(self):
session_id = self.store.create(TranslationSession(source_text="complete"), "alice")
self.store.update(
session_id,
{"phase4_result": "final"},
"alice",
current_phase=4,
)
clone_id = self.store.clone(session_id, "alice")
summary = next(item for item in self.store.list("alice") if item["session_id"] == clone_id)
self.assertEqual(summary["current_phase"], 4)
self.assertEqual(summary["status"], "completed")
class UserDatabaseTests(unittest.TestCase):
def test_password_change_increments_token_version(self):
with tempfile.TemporaryDirectory() as directory:
db = Database(Path(directory) / "users.db")
db.create_user("admin", "first", is_admin=True)
before = db.get_user("admin")
self.assertTrue(db.update_user("admin", password_hash="second"))
after = db.get_user("admin")
self.assertEqual(after["token_version"], before["token_version"] + 1)
def test_last_active_admin_cannot_be_removed(self):
with tempfile.TemporaryDirectory() as directory:
db = Database(Path(directory) / "users.db")
db.create_user("admin", "first", is_admin=True)
with self.assertRaises(LastAdminError):
db.update_user("admin", is_active=False)
class AdminApiTests(unittest.TestCase):
def setUp(self):
self.temp_dir = tempfile.TemporaryDirectory()
self.db = Database(Path(self.temp_dir.name) / "admin.db")
self.db.create_user("admin", "unused", is_admin=True)
self.database_patch = patch("backend.main.database", self.db)
self.database_patch.start()
app.dependency_overrides[require_admin] = lambda: "admin"
self.client = TestClient(app)
def tearDown(self):
app.dependency_overrides.pop(require_admin, None)
self.database_patch.stop()
self.temp_dir.cleanup()
def test_admin_can_create_and_deactivate_user(self):
response = self.client.post(
"/api/admin/users",
json={"id": "translator", "password": "secure-pass", "is_admin": False},
)
self.assertEqual(response.status_code, 201)
users = self.client.get("/api/admin/users").json()
self.assertEqual({user["id"] for user in users}, {"admin", "translator"})
response = self.client.patch(
"/api/admin/users/translator", json={"is_active": False}
)
self.assertEqual(response.status_code, 200)
self.assertFalse(self.db.get_user("translator")["is_active"])
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()