Add persistent multi-user sessions and Docker deployment
This commit is contained in:
parent
51a6e845d1
commit
104577c826
24 changed files with 2094 additions and 468 deletions
23
.dockerignore
Normal file
23
.dockerignore
Normal 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*
|
||||
11
.env.example
11
.env.example
|
|
@ -1,8 +1,15 @@
|
|||
# JWT 시그니용 키 (변경 권장)
|
||||
JWT_SECRET_KEY=my-super-secret-key-change-me
|
||||
|
||||
# JWT_SECRET_KEY 미설정 시 생성할 키 파일 (기본: config/.jwt-secret)
|
||||
# JWT_SECRET_FILE=config/.jwt-secret
|
||||
# JWT_SECRET_KEY 미설정 시 생성할 키 파일 (기본: data/.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)
|
||||
LLMS_CONFIG_PATH=config/llms.json
|
||||
|
|
|
|||
6
.gitignore
vendored
6
.gitignore
vendored
|
|
@ -10,6 +10,12 @@ __pycache__/
|
|||
# Build output
|
||||
frontend/dist/
|
||||
|
||||
# Persistent application data
|
||||
data/
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
|
||||
# Env files
|
||||
.env
|
||||
config/.jwt-secret
|
||||
|
|
|
|||
18
AGENTS.md
18
AGENTS.md
|
|
@ -32,7 +32,8 @@ LLM-translator/
|
|||
│ ├── llm_client.py # OpenAI-compatible client (async)
|
||||
│ ├── models.py # Pydantic schemas for all API I/O
|
||||
│ ├── prompts.py # Phase 1–4 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
|
||||
│ ├── src/
|
||||
│ │ ├── 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
|
||||
│ │ ├── stores/
|
||||
│ │ │ ├── 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
|
||||
│ │ ├── api.ts # Axios client with JWT interceptor
|
||||
│ │ └── 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.
|
||||
- **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()`.
|
||||
- **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.
|
||||
|
|
@ -77,7 +80,8 @@ LLM-translator/
|
|||
### Frontend
|
||||
|
||||
- **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.
|
||||
- **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)
|
||||
cd frontend && npm run build && cd ..
|
||||
|
||||
# 3. Configure LLM servers and users
|
||||
# Edit config/llms.json and config/users.json directly
|
||||
# 3. Configure LLM servers and initial users
|
||||
# users.json is imported once; later users are managed in the admin UI
|
||||
|
||||
# 4. Start server
|
||||
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
|
||||
|
||||
- **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
|
||||
|
||||
|
|
|
|||
95
DEPLOYMENT.md
Normal file
95
DEPLOYMENT.md
Normal 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
39
Dockerfile
Normal 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"]
|
||||
15
README.md
15
README.md
|
|
@ -10,6 +10,9 @@
|
|||
- **세로 3열 비교 UI**: Phase 1 / Phase 3 / Phase 4 결과를 한눈에 비교
|
||||
- **다크모드 지원**: 시스템 설정 자동 감지 + 수동 토글 (localStorage 지속화)
|
||||
- **JWT 기반 인증**: config-file 기반 ID/PW 로그인
|
||||
- **다중 사용자 및 영속 세션**: 사용자별 SQLite 세션 저장과 동일 세션 재개
|
||||
- **직전 버전 복원**: 번역을 덮어쓰기 전 세션 snapshot 보존
|
||||
- **관리자 사용자 관리**: 계정 생성, 비밀번호 초기화, 활성화 및 권한 관리
|
||||
- **단일 서버 배포**: FastAPI가 API + 정적 프론트엔드 동시에 서빙
|
||||
|
||||
## 빠른 시작
|
||||
|
|
@ -64,6 +67,9 @@ uvicorn backend.main:app --host 0.0.0.0 --port 8000
|
|||
| `LLMS_CONFIG_PATH` | LLM 설정 파일 경로 | `config/llms.json` |
|
||||
| `USERS_CONFIG_PATH` | 사용자 설정 파일 경로 | `config/users.json` |
|
||||
| `PORT` | 서버 포트 | `8000` |
|
||||
| `DATABASE_PATH` | SQLite DB 경로 | `data/translator.db` |
|
||||
| `BOOTSTRAP_ADMIN_ID` | 최초 관리자 ID | 없음 |
|
||||
| `BOOTSTRAP_ADMIN_PASSWORD` | 최초 관리자 비밀번호 | 없음 |
|
||||
| `LLM_TIMEOUT_SECONDS` | 개별 LLM 호출 제한 시간 | `180` |
|
||||
| `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 복구를 거쳐 일반 번역 결과로 폴백합니다.
|
||||
|
||||
## 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
|
||||
|
|
|
|||
215
backend/auth.py
215
backend/auth.py
|
|
@ -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 os
|
||||
import secrets
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
|
@ -10,9 +11,9 @@ import bcrypt
|
|||
import jwt
|
||||
from dotenv import load_dotenv
|
||||
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
|
||||
load_dotenv(PROJECT_ROOT / ".env")
|
||||
|
|
@ -22,113 +23,201 @@ def _load_jwt_secret() -> str:
|
|||
configured = os.getenv("JWT_SECRET_KEY")
|
||||
if configured:
|
||||
return configured
|
||||
|
||||
secret_path = Path(
|
||||
os.getenv("JWT_SECRET_FILE", str(PROJECT_ROOT / "config" / ".jwt-secret"))
|
||||
os.getenv("JWT_SECRET_FILE", str(PROJECT_ROOT / "data" / ".jwt-secret"))
|
||||
)
|
||||
secret_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
secret = secret_path.read_text(encoding="utf-8").strip()
|
||||
if secret:
|
||||
return secret
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
secret = secrets.token_urlsafe(48)
|
||||
try:
|
||||
descriptor = os.open(secret_path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
||||
except FileExistsError:
|
||||
secret = secret_path.read_text(encoding="utf-8").strip()
|
||||
if secret:
|
||||
return secret
|
||||
existing = secret_path.read_text(encoding="utf-8").strip()
|
||||
if existing:
|
||||
return existing
|
||||
raise RuntimeError(f"JWT secret file is empty: {secret_path}")
|
||||
except OSError as exc:
|
||||
raise RuntimeError(
|
||||
"Set JWT_SECRET_KEY because the JWT secret file could not be created: "
|
||||
f"{secret_path}"
|
||||
) from exc
|
||||
|
||||
with os.fdopen(descriptor, "w", encoding="utf-8") as secret_file:
|
||||
secret_file.write(secret)
|
||||
logging.getLogger(__name__).warning("Created JWT secret file at %s", secret_path)
|
||||
return secret
|
||||
|
||||
|
||||
JWT_SECRET_KEY = _load_jwt_secret()
|
||||
JWT_ALGORITHM = "HS256"
|
||||
JWT_EXPIRE_HOURS = int(os.getenv("JWT_EXPIRE_HOURS", "24"))
|
||||
|
||||
security = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
# ── User Loading ───────────────────────────────────────
|
||||
|
||||
def _load_users_file():
|
||||
"""Load users from config file. Auto-hash plain-text passwords on first run."""
|
||||
import json as j
|
||||
config_path = os.getenv(
|
||||
"USERS_CONFIG_PATH", str(Path(__file__).parent.parent / "config" / "users.json")
|
||||
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"))
|
||||
)
|
||||
with open(config_path, encoding="utf-8") as f:
|
||||
users_raw = j.load(f)
|
||||
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
|
||||
|
||||
# Migrate plain-text passwords to bcrypt hashes (one-time on startup)
|
||||
migrated = False
|
||||
for user in users_raw:
|
||||
if "password_plain" in user and "password" not in user:
|
||||
pw_bytes = user.pop("password_plain").encode("utf-8")
|
||||
user["password"] = bcrypt.hashpw(pw_bytes, bcrypt.gensalt()).decode("utf-8")
|
||||
migrated = True
|
||||
if contained_plaintext:
|
||||
temporary_path = config_path.with_suffix(config_path.suffix + ".tmp")
|
||||
try:
|
||||
temporary_path.write_text(
|
||||
json.dumps(sanitized_users, ensure_ascii=False, indent=2) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
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
|
||||
|
||||
if migrated:
|
||||
with open(config_path, "w", encoding="utf-8") as f:
|
||||
j.dump(users_raw, f, ensure_ascii=False, indent=2)
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
try:
|
||||
for user_id, password_hash, is_admin in records:
|
||||
connection.execute(
|
||||
"INSERT OR IGNORE INTO users(id, password_hash, is_admin, is_active, "
|
||||
"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))
|
||||
|
||||
return users_raw
|
||||
if database.count_admins() == 0:
|
||||
admin_id = os.getenv("BOOTSTRAP_ADMIN_ID")
|
||||
admin_password = os.getenv("BOOTSTRAP_ADMIN_PASSWORD")
|
||||
if admin_id and admin_password:
|
||||
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):
|
||||
for u in _users_db:
|
||||
if u["id"] == username:
|
||||
return u
|
||||
return None
|
||||
return database.get_user(username)
|
||||
|
||||
|
||||
# ── Token Helpers ─────────────────────────────────────
|
||||
|
||||
def create_access_token(data: dict) -> str:
|
||||
to_encode = data.copy()
|
||||
expire = datetime.now(timezone.utc) + timedelta(hours=JWT_EXPIRE_HOURS)
|
||||
to_encode.update({"exp": expire})
|
||||
return jwt.encode(to_encode, JWT_SECRET_KEY, algorithm=JWT_ALGORITHM)
|
||||
payload = data.copy()
|
||||
payload["exp"] = datetime.now(timezone.utc) + timedelta(hours=JWT_EXPIRE_HOURS)
|
||||
return jwt.encode(payload, JWT_SECRET_KEY, algorithm=JWT_ALGORITHM)
|
||||
|
||||
|
||||
def decode_access_token(token: str) -> dict | None:
|
||||
try:
|
||||
return jwt.decode(token, JWT_SECRET_KEY, algorithms=[JWT_ALGORITHM])
|
||||
except jwt.ExpiredSignatureError:
|
||||
return None
|
||||
except jwt.InvalidTokenError:
|
||||
except (jwt.ExpiredSignatureError, jwt.InvalidTokenError):
|
||||
return None
|
||||
|
||||
|
||||
# ── Dependency ───────────────────────────────────────
|
||||
|
||||
async def get_current_user(
|
||||
credentials: HTTPAuthorizationCredentials | None = Depends(security),
|
||||
) -> str:
|
||||
"""Extract and validate JWT, returning the user_id."""
|
||||
def _authenticated_user(credentials: HTTPAuthorizationCredentials | None) -> dict:
|
||||
if credentials is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="인증이 필요합니다",
|
||||
)
|
||||
raise HTTPException(status_code=401, detail="인증이 필요합니다")
|
||||
payload = decode_access_token(credentials.credentials)
|
||||
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(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
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
459
backend/database.py
Normal 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()
|
||||
261
backend/main.py
261
backend/main.py
|
|
@ -3,6 +3,7 @@
|
|||
import json
|
||||
import os
|
||||
import re
|
||||
import sqlite3
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
from time import monotonic
|
||||
|
|
@ -14,7 +15,8 @@ from openai import APITimeoutError, OpenAIError
|
|||
from pydantic import ValidationError
|
||||
|
||||
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 (
|
||||
chat_complete,
|
||||
get_llm_configs,
|
||||
|
|
@ -22,16 +24,20 @@ from backend.llm_client import (
|
|||
)
|
||||
from backend.models import (
|
||||
CreateSessionRequest,
|
||||
CreateUserRequest,
|
||||
LoginRequest,
|
||||
Phase1Result,
|
||||
Phase2ConfirmRequest,
|
||||
PhaseUpdateRequest,
|
||||
ProperNoun,
|
||||
SessionResponse,
|
||||
SessionSummary,
|
||||
TokenResponse,
|
||||
TranslationChunk,
|
||||
UserInfo,
|
||||
TranslationSession,
|
||||
UpdateUserRequest,
|
||||
UserSummary,
|
||||
)
|
||||
from backend.prompts import (
|
||||
SYSTEM_PROMPT_PHASE1,
|
||||
|
|
@ -41,7 +47,7 @@ from backend.prompts import (
|
|||
build_phase3_user_prompt,
|
||||
build_phase4_user_prompt,
|
||||
)
|
||||
from backend.sessions import SessionConflictError, session_store
|
||||
from backend.sessions import SessionArchivedError, SessionConflictError, session_store
|
||||
|
||||
|
||||
# ── App Setup ────────────────────────────────────────
|
||||
|
|
@ -69,20 +75,63 @@ async def login(req: LoginRequest):
|
|||
user = auth_mod.get_user_by_username(req.id)
|
||||
try:
|
||||
authenticated = user is not None and _bcrypt.checkpw(
|
||||
req.password.encode("utf-8"), user["password"].encode("utf-8")
|
||||
req.password.encode("utf-8"), user["password_hash"].encode("utf-8")
|
||||
)
|
||||
except (KeyError, ValueError):
|
||||
authenticated = False
|
||||
if not authenticated:
|
||||
raise HTTPException(status_code=401, detail="ID 또는 비밀번호가 틀렸습니다")
|
||||
|
||||
token = auth_mod.create_access_token({"user_id": user["id"]})
|
||||
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)
|
||||
|
||||
|
||||
@app.get("/api/auth/me")
|
||||
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 ────────────────────────────────
|
||||
|
|
@ -117,16 +166,28 @@ async def create_session(
|
|||
model_phase3=req.model_phase3,
|
||||
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}
|
||||
|
||||
|
||||
@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}")
|
||||
async def get_session(session_id: str, user_id: str = Depends(get_current_user)):
|
||||
data = session_store.get(session_id, user_id)
|
||||
if data is None:
|
||||
raise HTTPException(status_code=404, detail="세션을 찾을 수 없습니다")
|
||||
return SessionResponse(session_id=session_id, data=data).model_dump()
|
||||
return _session_response(session_id, user_id, data)
|
||||
|
||||
|
||||
@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}
|
||||
|
||||
|
||||
@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}")
|
||||
async def update_session(
|
||||
session_id: str,
|
||||
|
|
@ -154,6 +257,8 @@ async def update_session(
|
|||
user_id: str = Depends(get_current_user),
|
||||
):
|
||||
data = req.model_dump(exclude_none=True)
|
||||
title = data.pop("title", None)
|
||||
expected_version = data.pop("expected_version", None)
|
||||
_validate_model_aliases(data)
|
||||
current = session_store.get(session_id, user_id)
|
||||
if current is None:
|
||||
|
|
@ -166,6 +271,7 @@ async def update_session(
|
|||
"phase1_chunks": [],
|
||||
"phase2_proper_nouns": [],
|
||||
"phase2_style": "",
|
||||
"phase2_confirmed": False,
|
||||
"phase3_result": "",
|
||||
"phase3_chunks": [],
|
||||
"phase4_result": "",
|
||||
|
|
@ -181,10 +287,34 @@ async def update_session(
|
|||
elif changed & {"model_phase4"}:
|
||||
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:
|
||||
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 ───────────────────────────
|
||||
|
|
@ -196,6 +326,7 @@ async def run_phase1(session_id: str, user_id: str = Depends(get_current_user)):
|
|||
if snapshot is None:
|
||||
raise HTTPException(status_code=404, detail="세션을 찾을 수 없습니다")
|
||||
session, version = snapshot
|
||||
_ensure_not_archived(session_id, user_id)
|
||||
|
||||
alias = session.model_phase1
|
||||
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_style": result.style,
|
||||
"phase2_confirmed": False,
|
||||
"phase3_result": "",
|
||||
"phase3_chunks": [],
|
||||
"phase4_result": "",
|
||||
"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:
|
||||
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")
|
||||
|
|
@ -269,20 +408,38 @@ async def run_phase2(
|
|||
session = session_store.get(session_id, user_id)
|
||||
if session is None:
|
||||
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 = {
|
||||
"phase2_proper_nouns": [pn.model_dump() for pn in req.proper_nouns],
|
||||
"phase2_style": req.style,
|
||||
"phase2_confirmed": True,
|
||||
"phase3_result": "",
|
||||
"phase3_chunks": [],
|
||||
"phase4_result": "",
|
||||
"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:
|
||||
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")
|
||||
|
|
@ -292,6 +449,7 @@ async def run_phase3(session_id: str, user_id: str = Depends(get_current_user)):
|
|||
if snapshot is None:
|
||||
raise HTTPException(status_code=404, detail="세션을 찾을 수 없습니다")
|
||||
session, version = snapshot
|
||||
_ensure_not_archived(session_id, user_id)
|
||||
|
||||
if not session.phase1_result:
|
||||
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": "",
|
||||
"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:
|
||||
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")
|
||||
|
|
@ -348,6 +513,7 @@ async def run_phase4(session_id: str, user_id: str = Depends(get_current_user)):
|
|||
if snapshot is None:
|
||||
raise HTTPException(status_code=404, detail="세션을 찾을 수 없습니다")
|
||||
session, version = snapshot
|
||||
_ensure_not_archived(session_id, user_id)
|
||||
|
||||
if not session.phase3_result:
|
||||
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),
|
||||
"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:
|
||||
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 ───────────────────────────────────────────
|
||||
|
|
@ -627,17 +800,55 @@ async def _chat_complete(
|
|||
|
||||
|
||||
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:
|
||||
try:
|
||||
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:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="처리 중 세션이 변경되었습니다. 현재 상태에서 단계를 다시 실행하세요",
|
||||
) 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:
|
||||
|
|
@ -666,6 +877,18 @@ def _extract_json(text: str) -> str:
|
|||
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) ───────────────────
|
||||
|
||||
frontend_dist = os.getenv(
|
||||
|
|
|
|||
|
|
@ -17,6 +17,27 @@ class TokenResponse(BaseModel):
|
|||
|
||||
class UserInfo(BaseModel):
|
||||
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 ───────────────────────────────────────
|
||||
|
|
@ -42,7 +63,7 @@ class Phase1Result(BaseModel):
|
|||
proper_nouns: list[ProperNoun] # 추출된 고유명사 리스트
|
||||
summary: str # 내용 요약
|
||||
style: str # 문체 분석 결과
|
||||
warnings: list[str] = [] # JSON 복구/폴백 등 품질 관련 알림
|
||||
warnings: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class TranslationChunk(BaseModel):
|
||||
|
|
@ -69,11 +90,12 @@ class TranslationSession(BaseModel):
|
|||
model_phase3: str = "" # Phase 3 LLM 모델 alias
|
||||
model_phase4: str = "" # Phase 4 LLM 모델 alias
|
||||
phase1_result: Phase1Result | None = None # 초벌 번역 결과
|
||||
phase1_chunks: list[TranslationChunk] = [] # 장문 분할 시 원문/초벌 번역 쌍
|
||||
phase2_proper_nouns: list[ProperNoun] = [] # Phase 2에서 최종 확정된 고유명사
|
||||
phase1_chunks: list[TranslationChunk] = Field(default_factory=list)
|
||||
phase2_proper_nouns: list[ProperNoun] = Field(default_factory=list)
|
||||
phase2_style: str = "" # Phase 2에서 최종 확정된 문체
|
||||
phase2_confirmed: bool = False
|
||||
phase3_result: str = "" # 재번역 결과
|
||||
phase3_chunks: list[str] = [] # 장문 분할 재번역 결과
|
||||
phase3_chunks: list[str] = Field(default_factory=list)
|
||||
phase4_result: str = "" # 마무리 다듬기 결과
|
||||
progress: PhaseProgress | None = None # 장기 작업 진행 상태
|
||||
|
||||
|
|
@ -88,6 +110,7 @@ class CreateSessionRequest(BaseModel):
|
|||
model_phase2: str = ""
|
||||
model_phase3: str = ""
|
||||
model_phase4: str = ""
|
||||
title: str | None = Field(default=None, max_length=120)
|
||||
|
||||
|
||||
class PhaseUpdateRequest(BaseModel):
|
||||
|
|
@ -100,6 +123,8 @@ class PhaseUpdateRequest(BaseModel):
|
|||
model_phase2: str | None = None
|
||||
model_phase3: str | None = None
|
||||
model_phase4: str | None = None
|
||||
title: str | None = Field(default=None, max_length=120)
|
||||
expected_version: int
|
||||
|
||||
|
||||
class Phase2ConfirmRequest(BaseModel):
|
||||
|
|
@ -107,6 +132,7 @@ class Phase2ConfirmRequest(BaseModel):
|
|||
|
||||
proper_nouns: list[ProperNoun]
|
||||
style: str
|
||||
expected_version: int
|
||||
|
||||
|
||||
# ── API Responses ──────────────────────────────────────
|
||||
|
|
@ -114,3 +140,29 @@ class Phase2ConfirmRequest(BaseModel):
|
|||
class SessionResponse(BaseModel):
|
||||
session_id: str
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1,37 +1,25 @@
|
|||
"""In-memory translation session store with 24h TTL."""
|
||||
"""Persistent translation session store with transient in-memory progress."""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone, timedelta
|
||||
import threading
|
||||
from typing import Any
|
||||
|
||||
from backend.models import TranslationSession
|
||||
|
||||
|
||||
class SessionConflictError(Exception):
|
||||
"""Raised when a long-running phase tries to update a changed session."""
|
||||
from backend.database import (
|
||||
Database,
|
||||
SessionArchivedError,
|
||||
SessionConflictError,
|
||||
database,
|
||||
)
|
||||
from backend.models import PhaseProgress, TranslationSession
|
||||
|
||||
|
||||
class SessionStore:
|
||||
"""Process-local in-memory store for user-owned translation sessions.
|
||||
|
||||
Each session is keyed by a UUID and has a 24-hour TTL.
|
||||
Expired sessions are lazily cleaned on access.
|
||||
"""
|
||||
|
||||
def __init__(self, ttl_hours: int = 24):
|
||||
self._store: dict[str, dict[str, Any]] = {}
|
||||
self.ttl_hours = ttl_hours
|
||||
def __init__(self, db: Database | None = None):
|
||||
self.db = db or database
|
||||
self._progress: dict[tuple[str, str], dict[str, Any]] = {}
|
||||
self._progress_lock = threading.RLock()
|
||||
|
||||
def create(self, session_data: TranslationSession, owner_id: str) -> str:
|
||||
session_id = uuid.uuid4().hex[:16]
|
||||
self._store[session_id] = {
|
||||
"data": session_data.model_dump(),
|
||||
"owner_id": owner_id,
|
||||
"version": 0,
|
||||
"created_at": datetime.now(timezone.utc),
|
||||
"updated_at": datetime.now(timezone.utc),
|
||||
}
|
||||
return session_id
|
||||
return self.db.create_session(session_data, owner_id)
|
||||
|
||||
def get(self, session_id: str, owner_id: str) -> TranslationSession | None:
|
||||
result = self.get_with_version(session_id, owner_id)
|
||||
|
|
@ -40,18 +28,15 @@ class SessionStore:
|
|||
def get_with_version(
|
||||
self, session_id: str, owner_id: str
|
||||
) -> tuple[TranslationSession, int] | None:
|
||||
entry = self._store.get(session_id)
|
||||
if entry is None or entry["owner_id"] != owner_id:
|
||||
result = self.db.get_session(session_id, owner_id)
|
||||
if not result:
|
||||
return None
|
||||
# Check TTL
|
||||
now = datetime.now(timezone.utc)
|
||||
age = now - entry["updated_at"]
|
||||
if age > timedelta(hours=self.ttl_hours):
|
||||
del self._store[session_id]
|
||||
return None
|
||||
# Touch (refresh TTL)
|
||||
entry["updated_at"] = now
|
||||
return TranslationSession(**entry["data"]), entry["version"]
|
||||
session, version, _ = result
|
||||
with self._progress_lock:
|
||||
progress = self._progress.get((owner_id, session_id))
|
||||
if progress:
|
||||
session = session.model_copy(update={"progress": PhaseProgress(**progress)})
|
||||
return session, version
|
||||
|
||||
def update(
|
||||
self,
|
||||
|
|
@ -59,53 +44,68 @@ class SessionStore:
|
|||
data: dict[str, Any],
|
||||
owner_id: str,
|
||||
expected_version: int | None = None,
|
||||
*,
|
||||
snapshot_reason: str | None = None,
|
||||
current_phase: int | None = None,
|
||||
) -> TranslationSession | None:
|
||||
entry = self._store.get(session_id)
|
||||
if entry is None or entry["owner_id"] != owner_id:
|
||||
result = self.db.update_session(
|
||||
session_id,
|
||||
owner_id,
|
||||
data,
|
||||
expected_version=expected_version,
|
||||
snapshot_reason=snapshot_reason,
|
||||
current_phase=current_phase,
|
||||
)
|
||||
if not result:
|
||||
return None
|
||||
now = datetime.now(timezone.utc)
|
||||
if now - entry["updated_at"] > timedelta(hours=self.ttl_hours):
|
||||
del self._store[session_id]
|
||||
return None
|
||||
if expected_version is not None and entry["version"] != expected_version:
|
||||
raise SessionConflictError
|
||||
entry["data"].update(data)
|
||||
entry["version"] += 1
|
||||
entry["updated_at"] = now
|
||||
return TranslationSession(**entry["data"])
|
||||
self.set_progress(session_id, None, owner_id)
|
||||
return result[0]
|
||||
|
||||
def delete(self, session_id: str, owner_id: str) -> bool:
|
||||
entry = self._store.get(session_id)
|
||||
if entry is not None and entry["owner_id"] == owner_id:
|
||||
del self._store[session_id]
|
||||
return True
|
||||
return False
|
||||
self.set_progress(session_id, None, owner_id)
|
||||
return self.db.delete_session(session_id, owner_id)
|
||||
|
||||
def set_progress(
|
||||
self, session_id: str, progress: dict[str, Any] | None, owner_id: str
|
||||
) -> bool:
|
||||
"""Update transient progress without changing the semantic session version."""
|
||||
entry = self._store.get(session_id)
|
||||
if entry is None or entry["owner_id"] != owner_id:
|
||||
return False
|
||||
now = datetime.now(timezone.utc)
|
||||
if now - entry["updated_at"] > timedelta(hours=self.ttl_hours):
|
||||
del self._store[session_id]
|
||||
return False
|
||||
entry["data"]["progress"] = progress
|
||||
entry["updated_at"] = now
|
||||
key = (owner_id, session_id)
|
||||
with self._progress_lock:
|
||||
if progress is None:
|
||||
self._progress.pop(key, None)
|
||||
else:
|
||||
self._progress[key] = progress
|
||||
return True
|
||||
|
||||
def cleanup_expired(self):
|
||||
"""Remove all expired sessions."""
|
||||
now = datetime.now(timezone.utc)
|
||||
expired_keys = [
|
||||
k for k, v in self._store.items()
|
||||
if (now - v["updated_at"]) > timedelta(hours=self.ttl_hours)
|
||||
def list(self, owner_id: str, include_archived: bool = False, limit: int = 50):
|
||||
return self.db.list_sessions(
|
||||
owner_id, include_archived=include_archived, limit=limit
|
||||
)
|
||||
|
||||
def metadata(self, session_id: str, owner_id: str) -> dict[str, Any] | None:
|
||||
result = self.db.get_session(session_id, owner_id)
|
||||
return result[2] if result else None
|
||||
|
||||
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)
|
||||
|
||||
|
||||
session_store = SessionStore()
|
||||
|
||||
__all__ = [
|
||||
"SessionArchivedError",
|
||||
"SessionConflictError",
|
||||
"SessionStore",
|
||||
"session_store",
|
||||
]
|
||||
for k in expired_keys:
|
||||
del self._store[k]
|
||||
|
||||
|
||||
# Singleton instance
|
||||
session_store = SessionStore(ttl_hours=24)
|
||||
|
|
|
|||
41
compose.yml
Normal file
41
compose.yml
Normal 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
1
config/users.docker.json
Normal file
|
|
@ -0,0 +1 @@
|
|||
[]
|
||||
|
|
@ -1,10 +1,16 @@
|
|||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
import { useAuthStore } from './stores/auth'
|
||||
import LoginView from './views/LoginView.vue'
|
||||
import TranslatorView from './views/TranslatorView.vue'
|
||||
import AdminView from './views/AdminView.vue'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const page = ref<'translator' | 'admin'>('translator')
|
||||
|
||||
watch(() => auth.isLoggedIn, (loggedIn) => {
|
||||
if (!loggedIn) page.value = 'translator'
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
auth.check()
|
||||
|
|
@ -13,8 +19,10 @@ onMounted(() => {
|
|||
|
||||
<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">
|
||||
<!-- Show Login if not authenticated, otherwise Translator -->
|
||||
<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>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
/** Axios-based API client with JWT token management */
|
||||
|
||||
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'
|
||||
|
||||
|
|
@ -44,11 +47,29 @@ export async function logout(): Promise<void> {
|
|||
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')
|
||||
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 ───────────────────────────────────────────
|
||||
|
||||
export async function listModels(): Promise<LLMModel[]> {
|
||||
|
|
@ -68,6 +89,11 @@ export async function getSession(sessionId: string): Promise<SessionResponse> {
|
|||
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(
|
||||
sessionId: string,
|
||||
): Promise<{ progress: PhaseProgress | null }> {
|
||||
|
|
@ -81,12 +107,35 @@ export async function deleteSession(sessionId: string): Promise<void> {
|
|||
|
||||
export async function updateSession(
|
||||
sessionId: string,
|
||||
patch: Partial<SessionConfig>,
|
||||
patch: Partial<SessionConfig> & { title?: string; expected_version?: number },
|
||||
): Promise<SessionResponse> {
|
||||
const res = await api.patch(`/sessions/${sessionId}`, patch)
|
||||
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 ──────────────────────────────────
|
||||
|
||||
export async function runPhase1(sessionId: string): Promise<SessionResponse> {
|
||||
|
|
@ -98,8 +147,13 @@ export async function runPhase2(
|
|||
sessionId: string,
|
||||
proper_nouns: ProperNoun[],
|
||||
style: string,
|
||||
expected_version: number,
|
||||
): 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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ const props = defineProps<{
|
|||
label: string
|
||||
value: string
|
||||
models: LLMModel[]
|
||||
disabled?: boolean
|
||||
}>()
|
||||
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>
|
||||
<select
|
||||
:value="value"
|
||||
:disabled="disabled"
|
||||
@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 v-for="m in models" :key="m.alias" :value="m.alias">
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import type { ProperNoun } from '../types'
|
|||
const props = defineProps<{
|
||||
proper_nouns: ProperNoun[]
|
||||
style: string
|
||||
disabled?: boolean
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
(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>
|
||||
<input
|
||||
v-model="styleText"
|
||||
:disabled="disabled"
|
||||
@change="emitAll()"
|
||||
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>
|
||||
|
|
@ -65,6 +67,7 @@ function emitAll() {
|
|||
<td class="py-1 px-2">
|
||||
<input
|
||||
v-model="nouns[idx].final"
|
||||
:disabled="disabled"
|
||||
@change="emitAll()"
|
||||
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"
|
||||
|
|
|
|||
|
|
@ -4,12 +4,14 @@ import { AUTH_UNAUTHORIZED_EVENT, me as apiMe, logout as apiLogout } from '../ap
|
|||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
const user_id = ref<string | null>(null)
|
||||
const isAdmin = ref(false)
|
||||
const loaded = ref(false)
|
||||
|
||||
const isLoggedIn = computed(() => !!user_id.value && loaded.value)
|
||||
|
||||
function handleUnauthorized() {
|
||||
user_id.value = null
|
||||
isAdmin.value = false
|
||||
loaded.value = true
|
||||
}
|
||||
|
||||
|
|
@ -21,8 +23,10 @@ export const useAuthStore = defineStore('auth', () => {
|
|||
try {
|
||||
const data = await apiMe()
|
||||
user_id.value = data.user_id
|
||||
isAdmin.value = data.is_admin
|
||||
} catch {
|
||||
user_id.value = null
|
||||
isAdmin.value = false
|
||||
} finally {
|
||||
loaded.value = true
|
||||
}
|
||||
|
|
@ -31,7 +35,8 @@ export const useAuthStore = defineStore('auth', () => {
|
|||
async function logout() {
|
||||
await apiLogout()
|
||||
user_id.value = null
|
||||
isAdmin.value = false
|
||||
}
|
||||
|
||||
return { user_id, loaded, isLoggedIn, check, logout }
|
||||
return { user_id, isAdmin, loaded, isLoggedIn, check, logout }
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
import { defineStore } from 'pinia'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
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 { useAuthStore } from './auth'
|
||||
|
||||
const HISTORY_KEY = 'llm_translator_history'
|
||||
const MODELS_KEY = 'llm_translator_models'
|
||||
|
||||
interface SavedModels {
|
||||
|
|
@ -15,9 +16,10 @@ interface SavedModels {
|
|||
}
|
||||
|
||||
export const useTranslationStore = defineStore('translation', () => {
|
||||
const auth = useAuthStore()
|
||||
// ── State ────────────────────────────────────────
|
||||
const sessionId = ref<string | null>(null)
|
||||
const sessionTitle = ref('')
|
||||
const sessionVersion = ref(0)
|
||||
const archivedAt = ref<string | null>(null)
|
||||
const sourceText = ref('')
|
||||
const sourceLanguage = ref('auto')
|
||||
const targetLanguage = ref('한국어')
|
||||
|
|
@ -25,37 +27,41 @@ export const useTranslationStore = defineStore('translation', () => {
|
|||
const modelPhase3 = ref('')
|
||||
const modelPhase4 = ref('')
|
||||
|
||||
// Phase results
|
||||
const phase1Result = ref<Phase1Result | null>(null)
|
||||
const phase2ProperNouns = ref<ProperNoun[]>([])
|
||||
const phase2Style = ref('')
|
||||
const phase3Result = ref('')
|
||||
const phase4Result = ref('')
|
||||
|
||||
// UI state
|
||||
const loading = ref(false)
|
||||
const sessionLoading = ref(false)
|
||||
const historyLoading = ref(false)
|
||||
const saving = ref(false)
|
||||
const currentPhaseLoading = ref<number | null>(null)
|
||||
const error = ref<string | null>(null)
|
||||
const models = ref<LLMModel[]>([])
|
||||
const history = ref<HistoryEntry[]>([])
|
||||
const historySnapshot = ref(false)
|
||||
const history = ref<SessionSummary[]>([])
|
||||
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 progressRequestPending = false
|
||||
|
||||
// ── Computed ─────────────────────────────────────
|
||||
const phase1Done = computed(() => !!phase1Result.value)
|
||||
const phase2Ready = computed(() => phase1Done.value && !historySnapshot.value)
|
||||
const phase3Ready = computed(() => phase1Done.value && !historySnapshot.value)
|
||||
const phase4Ready = computed(() => !!phase3Result.value && !historySnapshot.value)
|
||||
const editable = computed(() => !archivedAt.value && !loading.value && !sessionLoading.value)
|
||||
const phase2Ready = computed(() => phase1Done.value && editable.value)
|
||||
const phase3Ready = computed(() => phase1Done.value && editable.value)
|
||||
const phase4Ready = computed(() => !!phase3Result.value && editable.value)
|
||||
|
||||
// ── Actions ──────────────────────────────────────
|
||||
|
||||
function getErrorMessage(error: unknown, fallback: string): string {
|
||||
if (isAxiosError<{ detail?: string }>(error)) {
|
||||
return error.response?.data?.detail || error.message || fallback
|
||||
function getErrorMessage(caught: unknown, fallback: string): string {
|
||||
if (isAxiosError<{ detail?: string }>(caught)) {
|
||||
return caught.response?.data?.detail || caught.message || fallback
|
||||
}
|
||||
return error instanceof Error ? error.message : fallback
|
||||
return caught instanceof Error ? caught.message : fallback
|
||||
}
|
||||
|
||||
function loadSavedModels(): SavedModels {
|
||||
|
|
@ -81,13 +87,21 @@ export const useTranslationStore = defineStore('translation', () => {
|
|||
async function loadModels() {
|
||||
try {
|
||||
models.value = await api.listModels()
|
||||
} catch (error: unknown) {
|
||||
console.error('Failed to load models:', error)
|
||||
} catch (caught: unknown) {
|
||||
error.value = getErrorMessage(caught, '모델 목록을 불러오지 못했습니다')
|
||||
}
|
||||
}
|
||||
|
||||
function newSession() {
|
||||
if (saveTimer) clearTimeout(saveTimer)
|
||||
if (phase2SaveTimer) clearTimeout(phase2SaveTimer)
|
||||
editGeneration += 1
|
||||
phase2EditGeneration += 1
|
||||
hydrating = true
|
||||
sessionId.value = null
|
||||
sessionTitle.value = ''
|
||||
sessionVersion.value = 0
|
||||
archivedAt.value = null
|
||||
sourceText.value = ''
|
||||
sourceLanguage.value = 'auto'
|
||||
targetLanguage.value = '한국어'
|
||||
|
|
@ -96,53 +110,22 @@ export const useTranslationStore = defineStore('translation', () => {
|
|||
phase2Style.value = ''
|
||||
phase3Result.value = ''
|
||||
phase4Result.value = ''
|
||||
progress.value = null
|
||||
error.value = null
|
||||
historySnapshot.value = false
|
||||
|
||||
const savedModels = loadSavedModels()
|
||||
const saved = loadSavedModels()
|
||||
const defaultModel = models.value[0]?.alias || ''
|
||||
modelPhase1.value = validModel(savedModels.p1, defaultModel)
|
||||
modelPhase3.value = validModel(savedModels.p3, defaultModel)
|
||||
modelPhase4.value = validModel(savedModels.p4, models.value[1]?.alias || defaultModel)
|
||||
modelPhase1.value = validModel(saved.p1, defaultModel)
|
||||
modelPhase3.value = validModel(saved.p3, defaultModel)
|
||||
modelPhase4.value = validModel(saved.p4, models.value[1]?.alias || defaultModel)
|
||||
hydrating = false
|
||||
}
|
||||
|
||||
function saveModelSelections() {
|
||||
try {
|
||||
localStorage.setItem(MODELS_KEY, JSON.stringify({
|
||||
p1: modelPhase1.value,
|
||||
p3: modelPhase3.value,
|
||||
p4: modelPhase4.value,
|
||||
}))
|
||||
} catch (error) {
|
||||
console.error('Failed to save model selections:', error)
|
||||
}
|
||||
}
|
||||
|
||||
watch([modelPhase1, modelPhase3, modelPhase4], saveModelSelections)
|
||||
watch([sourceText, sourceLanguage, targetLanguage, modelPhase1], () => {
|
||||
phase1Result.value = null
|
||||
phase2ProperNouns.value = []
|
||||
phase2Style.value = ''
|
||||
phase3Result.value = ''
|
||||
phase4Result.value = ''
|
||||
}, { flush: 'sync' })
|
||||
watch(modelPhase3, () => {
|
||||
phase3Result.value = ''
|
||||
phase4Result.value = ''
|
||||
}, { flush: 'sync' })
|
||||
watch(modelPhase4, () => {
|
||||
phase4Result.value = ''
|
||||
}, { flush: 'sync' })
|
||||
watch([phase2ProperNouns, phase2Style], () => {
|
||||
phase3Result.value = ''
|
||||
phase4Result.value = ''
|
||||
}, { deep: true, flush: 'sync' })
|
||||
|
||||
async function ensureSession() {
|
||||
if (!sessionId.value) {
|
||||
const res = await api.createSession(getSessionParams())
|
||||
sessionId.value = res.session_id
|
||||
}
|
||||
}
|
||||
|
||||
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() {
|
||||
if (!sessionId.value) return
|
||||
await api.updateSession(sessionId.value, getSessionParams())
|
||||
if (!sessionId.value || archivedAt.value) return
|
||||
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() {
|
||||
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,
|
||||
phase2ProperNouns.value.map(pn => ({ ...pn, final: pn.final || pn.suggested })),
|
||||
phase2ProperNouns.value.map(noun => ({ ...noun, final: noun.final || noun.suggested })),
|
||||
phase2Style.value,
|
||||
)
|
||||
phase2ProperNouns.value = res.data.phase2_proper_nouns || []
|
||||
phase2Style.value = res.data.phase2_style || ''
|
||||
sessionVersion.value,
|
||||
))
|
||||
}
|
||||
|
||||
async function pollProgress() {
|
||||
if (!sessionId.value || progressRequestPending) return
|
||||
progressRequestPending = true
|
||||
try {
|
||||
const response = await api.getSessionProgress(sessionId.value)
|
||||
progress.value = response.progress
|
||||
progress.value = (await api.getSessionProgress(sessionId.value)).progress
|
||||
} catch {
|
||||
// The phase request handles user-visible errors; polling is best-effort only.
|
||||
// Phase requests surface errors; progress polling is best-effort.
|
||||
} finally {
|
||||
progressRequestPending = false
|
||||
}
|
||||
|
|
@ -194,42 +370,27 @@ export const useTranslationStore = defineStore('translation', () => {
|
|||
}
|
||||
|
||||
function stopProgressPolling() {
|
||||
if (progressTimer !== null) {
|
||||
clearInterval(progressTimer)
|
||||
if (progressTimer !== null) clearInterval(progressTimer)
|
||||
progressTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
// ── Phase Execution ──────────────────────────────
|
||||
|
||||
async function executePhase1() {
|
||||
if (!sourceText.value.trim()) {
|
||||
error.value = '원문을 입력해주세요'
|
||||
if (!sourceText.value.trim() || !modelPhase1.value) {
|
||||
error.value = !sourceText.value.trim() ? '원문을 입력해주세요' : 'Phase 1 LLM 모델을 선택하세요'
|
||||
return
|
||||
}
|
||||
if (!modelPhase1.value) {
|
||||
error.value = 'Phase 1 LLM 모델을 선택하세요'
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
currentPhaseLoading.value = 1
|
||||
error.value = null
|
||||
phase3Result.value = ''
|
||||
phase4Result.value = ''
|
||||
try {
|
||||
await ensureSession()
|
||||
if (!sessionId.value) throw new Error('세션 생성 실패')
|
||||
await syncToServer()
|
||||
if (!sessionId.value) throw new Error('세션 생성 실패')
|
||||
startProgressPolling()
|
||||
const res = await api.runPhase1(sessionId.value)
|
||||
phase1Result.value = res.data.phase1_result
|
||||
phase2ProperNouns.value = res.data.phase2_proper_nouns || []
|
||||
phase2Style.value = res.data.phase2_style || ''
|
||||
historySnapshot.value = false
|
||||
saveToHistory()
|
||||
} catch (caughtError: unknown) {
|
||||
error.value = getErrorMessage(caughtError, 'Phase 1 실행 실패')
|
||||
hydrate(await api.runPhase1(sessionId.value))
|
||||
await loadHistory()
|
||||
} catch (caught: unknown) {
|
||||
error.value = getErrorMessage(caught, 'Phase 1 실행 실패')
|
||||
} finally {
|
||||
stopProgressPolling()
|
||||
loading.value = false
|
||||
|
|
@ -238,20 +399,15 @@ export const useTranslationStore = defineStore('translation', () => {
|
|||
}
|
||||
|
||||
async function executePhase2() {
|
||||
// Phase 2 is a user confirmation step — we just save the edited proper nouns and style
|
||||
if (!sessionId.value) return
|
||||
|
||||
loading.value = true
|
||||
currentPhaseLoading.value = 2
|
||||
error.value = null
|
||||
phase3Result.value = ''
|
||||
phase4Result.value = ''
|
||||
|
||||
try {
|
||||
await submitPhase2()
|
||||
saveToHistory()
|
||||
} catch (caughtError: unknown) {
|
||||
error.value = getErrorMessage(caughtError, 'Phase 2 확인 실패')
|
||||
await loadHistory()
|
||||
} catch (caught: unknown) {
|
||||
error.value = getErrorMessage(caught, 'Phase 2 확인 실패')
|
||||
} finally {
|
||||
loading.value = false
|
||||
currentPhaseLoading.value = null
|
||||
|
|
@ -263,29 +419,19 @@ export const useTranslationStore = defineStore('translation', () => {
|
|||
error.value = '먼저 Phase 1을 실행하세요'
|
||||
return
|
||||
}
|
||||
if (!modelPhase3.value && !modelPhase1.value) {
|
||||
error.value = 'Phase 3 LLM 모델을 선택하세요'
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
currentPhaseLoading.value = 3
|
||||
error.value = null
|
||||
phase3Result.value = ''
|
||||
phase4Result.value = ''
|
||||
|
||||
try {
|
||||
await ensureSession()
|
||||
if (!sessionId.value) throw new Error('세션 생성 실패')
|
||||
await syncToServer()
|
||||
await submitPhase2()
|
||||
saveToHistory()
|
||||
if (!sessionId.value) throw new Error('세션 생성 실패')
|
||||
startProgressPolling()
|
||||
const res = await api.runPhase3(sessionId.value)
|
||||
phase3Result.value = res.data.phase3_result || ''
|
||||
saveToHistory()
|
||||
} catch (caughtError: unknown) {
|
||||
error.value = getErrorMessage(caughtError, 'Phase 3 실행 실패')
|
||||
hydrate(await api.runPhase3(sessionId.value))
|
||||
await loadHistory()
|
||||
} catch (caught: unknown) {
|
||||
error.value = getErrorMessage(caught, 'Phase 3 실행 실패')
|
||||
} finally {
|
||||
stopProgressPolling()
|
||||
loading.value = false
|
||||
|
|
@ -298,21 +444,18 @@ export const useTranslationStore = defineStore('translation', () => {
|
|||
error.value = '먼저 Phase 3을 실행하세요'
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
currentPhaseLoading.value = 4
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
await ensureSession()
|
||||
if (!sessionId.value) throw new Error('세션 생성 실패')
|
||||
await syncToServer()
|
||||
if (!sessionId.value) throw new Error('세션 생성 실패')
|
||||
startProgressPolling()
|
||||
const res = await api.runPhase4(sessionId.value)
|
||||
phase4Result.value = res.data.phase4_result || ''
|
||||
saveToHistory()
|
||||
} catch (caughtError: unknown) {
|
||||
error.value = getErrorMessage(caughtError, 'Phase 4 실행 실패')
|
||||
hydrate(await api.runPhase4(sessionId.value))
|
||||
await loadHistory()
|
||||
} catch (caught: unknown) {
|
||||
error.value = getErrorMessage(caught, 'Phase 4 실행 실패')
|
||||
} finally {
|
||||
stopProgressPolling()
|
||||
loading.value = false
|
||||
|
|
@ -320,129 +463,75 @@ export const useTranslationStore = defineStore('translation', () => {
|
|||
}
|
||||
}
|
||||
|
||||
// ── History (localStorage) ───────────────────────
|
||||
|
||||
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()
|
||||
|
||||
async function archive(id: string, shouldArchive: boolean) {
|
||||
try {
|
||||
localStorage.setItem(historyStorageKey(), JSON.stringify(history.value))
|
||||
} catch (error) {
|
||||
console.error('Failed to save translation history:', error)
|
||||
if (shouldArchive) await api.archiveSession(id)
|
||||
else await api.restoreSession(id)
|
||||
if (sessionId.value === id) hydrate(await api.getSession(id))
|
||||
await loadHistory()
|
||||
} catch (caught: unknown) {
|
||||
error.value = getErrorMessage(caught, shouldArchive ? '보관하지 못했습니다' : '복원하지 못했습니다')
|
||||
}
|
||||
}
|
||||
|
||||
function historyStorageKey(): string {
|
||||
return `${HISTORY_KEY}:${auth.user_id || 'anonymous'}`
|
||||
}
|
||||
|
||||
function loadHistory() {
|
||||
async function clone(id: string) {
|
||||
try {
|
||||
const key = historyStorageKey()
|
||||
const raw = localStorage.getItem(key) || '[]'
|
||||
history.value = JSON.parse(raw) as HistoryEntry[]
|
||||
localStorage.removeItem(HISTORY_KEY)
|
||||
} catch {
|
||||
history.value = []
|
||||
const cloned = await api.cloneSession(id)
|
||||
await loadHistory()
|
||||
await openSession(cloned.session_id)
|
||||
} catch (caught: unknown) {
|
||||
error.value = getErrorMessage(caught, '세션을 복제하지 못했습니다')
|
||||
}
|
||||
}
|
||||
|
||||
function restoreFromHistory(entry: HistoryEntry) {
|
||||
sessionId.value = null
|
||||
sourceText.value = entry.data.source_text
|
||||
sourceLanguage.value = entry.data.source_language
|
||||
targetLanguage.value = entry.data.target_language
|
||||
const defaultModel = models.value[0]?.alias || ''
|
||||
modelPhase1.value = validModel(entry.data.model_phase1, defaultModel)
|
||||
modelPhase3.value = validModel(entry.data.model_phase3, defaultModel)
|
||||
modelPhase4.value = validModel(entry.data.model_phase4, models.value[1]?.alias || defaultModel)
|
||||
phase1Result.value = entry.data.phase1_result
|
||||
phase2ProperNouns.value = entry.data.phase2_proper_nouns || []
|
||||
phase2Style.value = entry.data.phase2_style || ''
|
||||
phase3Result.value = entry.data.phase3_result || ''
|
||||
phase4Result.value = entry.data.phase4_result || ''
|
||||
historySnapshot.value = true
|
||||
|
||||
// Save model selections for future sessions
|
||||
saveModelSelections()
|
||||
}
|
||||
|
||||
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,
|
||||
async function remove(id: string) {
|
||||
try {
|
||||
await api.deleteSession(id)
|
||||
if (sessionId.value === id) newSession()
|
||||
await loadHistory()
|
||||
} catch (caught: unknown) {
|
||||
error.value = getErrorMessage(caught, '세션을 삭제하지 못했습니다')
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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) {
|
||||
navigator.clipboard.writeText(text).catch(() => {
|
||||
error.value = '클립보드 복사에 실패했습니다'
|
||||
})
|
||||
navigator.clipboard.writeText(text).catch(() => { 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 url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = filename
|
||||
a.click()
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = filename
|
||||
link.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
function downloadMd(filename: string, content: string): void {
|
||||
// Add simple markdown formatting — frontmatter with metadata
|
||||
const md = `---
|
||||
source_language: ${sourceLanguage.value}
|
||||
target_language: ${targetLanguage.value}
|
||||
timestamp: ${new Date().toISOString()}
|
||||
---\n\n${content}`
|
||||
downloadTxt(filename.replace('.txt', '.md'), md)
|
||||
function downloadMd(filename: string, content: string) {
|
||||
const markdown = `---\nsource_language: ${sourceLanguage.value}\ntarget_language: ${targetLanguage.value}\ntimestamp: ${new Date().toISOString()}\n---\n\n${content}`
|
||||
downloadTxt(filename.replace('.txt', '.md'), markdown)
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
sessionId, sourceText, sourceLanguage, targetLanguage,
|
||||
modelPhase1, modelPhase3, modelPhase4,
|
||||
phase1Result, phase2ProperNouns, phase2Style,
|
||||
phase3Result, phase4Result,
|
||||
loading, currentPhaseLoading, error, models, history, progress,
|
||||
|
||||
// Computed
|
||||
phase1Done, phase2Ready, phase3Ready, phase4Ready,
|
||||
|
||||
// Actions
|
||||
loadModels, newSession, saveModelSelections, ensureSession, syncToServer,
|
||||
executePhase1, executePhase2, executePhase3, executePhase4,
|
||||
saveToHistory, loadHistory, restoreFromHistory,
|
||||
copyToClipboard, downloadTxt, downloadMd, getSessionData,
|
||||
sessionId, sessionTitle, sessionVersion, archivedAt, sourceText, sourceLanguage, targetLanguage,
|
||||
modelPhase1, modelPhase3, modelPhase4, phase1Result, phase2ProperNouns, phase2Style,
|
||||
phase3Result, phase4Result, loading, sessionLoading, historyLoading, saving,
|
||||
currentPhaseLoading, error, models, history, progress, phase1Done, phase2Ready,
|
||||
phase3Ready, phase4Ready, editable, loadModels, newSession, ensureSession, syncToServer,
|
||||
loadHistory, openSession, executePhase1, executePhase2, executePhase3, executePhase4,
|
||||
archive, clone, remove, restorePreviousRevision, copyToClipboard, downloadTxt, downloadMd,
|
||||
getSessionParams,
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ export interface TranslationSessionData {
|
|||
phase1_chunks?: Array<{ source_text: string; translated: string }>
|
||||
phase2_proper_nouns: ProperNoun[]
|
||||
phase2_style: string
|
||||
phase2_confirmed: boolean
|
||||
phase3_result: string
|
||||
phase3_chunks?: string[]
|
||||
phase4_result: string
|
||||
|
|
@ -60,13 +61,38 @@ export type SessionConfig = Pick<
|
|||
export interface SessionResponse {
|
||||
session_id: string
|
||||
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 HistoryEntry {
|
||||
id: string
|
||||
export interface SessionSummary {
|
||||
session_id: string
|
||||
title: string
|
||||
source_preview: string
|
||||
target_language: string
|
||||
timestamp: number
|
||||
data: TranslationSessionData
|
||||
status: string
|
||||
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
|
||||
}
|
||||
|
|
|
|||
126
frontend/src/views/AdminView.vue
Normal file
126
frontend/src/views/AdminView.vue
Normal 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>
|
||||
|
|
@ -11,11 +11,12 @@ const store = useTranslationStore()
|
|||
const auth = useAuthStore()
|
||||
const theme = useThemeStore()
|
||||
const showHistorySidebar = ref(false)
|
||||
defineEmits<{ (event: 'open-admin'): void }>()
|
||||
|
||||
onMounted(async () => {
|
||||
await store.loadModels()
|
||||
store.newSession()
|
||||
store.loadHistory()
|
||||
await store.loadHistory()
|
||||
})
|
||||
|
||||
// Language options
|
||||
|
|
@ -56,13 +57,26 @@ function handleNewTranslation() {
|
|||
}
|
||||
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>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen">
|
||||
<!-- 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">
|
||||
<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="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">
|
||||
|
|
@ -72,7 +86,7 @@ function handleNewTranslation() {
|
|||
<h1 class="text-lg font-bold text-gray-800 dark:text-gray-100">LLM 번역기</h1>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex flex-wrap items-center justify-end gap-2">
|
||||
<!-- Dark Mode Toggle -->
|
||||
<button
|
||||
@click="theme.toggle()"
|
||||
|
|
@ -84,13 +98,21 @@ function handleNewTranslation() {
|
|||
|
||||
<!-- History 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"
|
||||
title="히스토리"
|
||||
>
|
||||
🕐 히스토리 ({{ store.history.length }})
|
||||
</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 -->
|
||||
<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">
|
||||
|
|
@ -102,18 +124,24 @@ function handleNewTranslation() {
|
|||
|
||||
<!-- Main Content -->
|
||||
<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 -->
|
||||
<section class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-5">
|
||||
<div class="flex flex-wrap items-end gap-4">
|
||||
<LLMSelector label="Phase 1 — 초벌번역" v-model:value="store.modelPhase1" :models="store.models" />
|
||||
<LLMSelector label="Phase 3 — 재번역" v-model:value="store.modelPhase3" :models="store.models" />
|
||||
<LLMSelector label="Phase 4 — 마무리" v-model:value="store.modelPhase4" :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" :disabled="!store.editable" />
|
||||
<LLMSelector label="Phase 4 — 마무리" v-model:value="store.modelPhase4" :models="store.models" :disabled="!store.editable" />
|
||||
|
||||
<!-- Language Selectors -->
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide">출발어</label>
|
||||
<select
|
||||
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"
|
||||
>
|
||||
<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>
|
||||
<select
|
||||
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"
|
||||
>
|
||||
<option v-for="lang in targetLanguages" :key="lang.value" :value="lang.value">
|
||||
|
|
@ -137,9 +166,13 @@ function handleNewTranslation() {
|
|||
</div>
|
||||
|
||||
<!-- 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 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>
|
||||
</section>
|
||||
|
||||
|
|
@ -151,9 +184,10 @@ function handleNewTranslation() {
|
|||
</div>
|
||||
<textarea
|
||||
v-model="store.sourceText"
|
||||
:disabled="!store.editable"
|
||||
rows="10"
|
||||
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>
|
||||
</section>
|
||||
|
||||
|
|
@ -161,7 +195,7 @@ function handleNewTranslation() {
|
|||
<section class="flex flex-wrap gap-3">
|
||||
<button
|
||||
@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"
|
||||
>
|
||||
<span v-if="store.currentPhaseLoading === 1">⏳</span>
|
||||
|
|
@ -171,7 +205,7 @@ function handleNewTranslation() {
|
|||
|
||||
<button
|
||||
@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"
|
||||
>
|
||||
<span v-if="store.currentPhaseLoading === 2">⏳</span>
|
||||
|
|
@ -181,7 +215,7 @@ function handleNewTranslation() {
|
|||
|
||||
<button
|
||||
@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"
|
||||
>
|
||||
<span v-if="store.currentPhaseLoading === 3">⏳</span>
|
||||
|
|
@ -191,7 +225,7 @@ function handleNewTranslation() {
|
|||
|
||||
<button
|
||||
@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"
|
||||
>
|
||||
<span v-if="store.currentPhaseLoading === 4">⏳</span>
|
||||
|
|
@ -236,6 +270,7 @@ function handleNewTranslation() {
|
|||
<ProperNounsEditor
|
||||
:proper_nouns="store.phase2ProperNouns"
|
||||
:style="store.phase2Style"
|
||||
:disabled="!store.editable"
|
||||
@update:proper_nouns="(v) => store.phase2ProperNouns = v"
|
||||
@update:style="(v) => store.phase2Style = v"
|
||||
/>
|
||||
|
|
@ -259,31 +294,45 @@ function handleNewTranslation() {
|
|||
|
||||
<!-- History Sidebar (overlay) -->
|
||||
<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">
|
||||
번역 히스토리
|
||||
<button @click="showHistorySidebar = false" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200">✕</button>
|
||||
</h3>
|
||||
|
||||
<!-- History List -->
|
||||
<div v-if="store.history.length === 0" class="text-sm text-gray-400 italic mt-4">
|
||||
<div class="mb-4 flex items-center justify-between text-xs text-gray-400">
|
||||
<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>
|
||||
|
||||
<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
|
||||
@click="store.restoreFromHistory(entry); showHistorySidebar = false"
|
||||
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"
|
||||
@click="openHistorySession(entry.session_id)"
|
||||
: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">
|
||||
{{ new Date(entry.timestamp).toLocaleString('ko-KR') }}
|
||||
<div class="mb-1 flex items-center justify-between gap-2 text-xs text-gray-400">
|
||||
<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 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 || '(빈 원문)' }}
|
||||
</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>
|
||||
<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>
|
||||
</ul>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,19 +1,43 @@
|
|||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
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.main import _split_text, app
|
||||
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):
|
||||
@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):
|
||||
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"})
|
||||
self.client = TestClient(app, headers={"Authorization": f"Bearer {token}"})
|
||||
self.model = get_llm_configs()[0].alias
|
||||
|
|
@ -35,9 +59,13 @@ class ApiTests(unittest.TestCase):
|
|||
return response.json()["session_id"]
|
||||
|
||||
def test_translation_routes_require_authentication(self):
|
||||
override = app.dependency_overrides.pop(get_current_user)
|
||||
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)
|
||||
|
||||
def test_progress_endpoint_returns_only_transient_progress(self):
|
||||
|
|
@ -89,28 +117,25 @@ class ApiTests(unittest.TestCase):
|
|||
)
|
||||
responses = [phase1, "다시 번역", "최종 번역"]
|
||||
with patch("backend.main.chat_complete", new=AsyncMock(side_effect=responses)):
|
||||
self.assertEqual(
|
||||
self.client.post(f"/api/translate/{session_id}/phase1").status_code,
|
||||
200,
|
||||
)
|
||||
self.assertEqual(
|
||||
self.client.post(
|
||||
response = self.client.post(f"/api/translate/{session_id}/phase1")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
response = self.client.post(
|
||||
f"/api/translate/{session_id}/phase2",
|
||||
json={"proper_nouns": [], "style": "중립적"},
|
||||
).status_code,
|
||||
200,
|
||||
)
|
||||
self.assertEqual(
|
||||
self.client.post(f"/api/translate/{session_id}/phase3").status_code,
|
||||
200,
|
||||
)
|
||||
self.assertEqual(
|
||||
self.client.post(f"/api/translate/{session_id}/phase4").status_code,
|
||||
200,
|
||||
json={
|
||||
"proper_nouns": [],
|
||||
"style": "중립적",
|
||||
"expected_version": response.json()["version"],
|
||||
},
|
||||
)
|
||||
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(
|
||||
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)
|
||||
data = response.json()["data"]
|
||||
|
|
@ -147,6 +172,47 @@ class ApiTests(unittest.TestCase):
|
|||
self.assertEqual(response.json()["data"]["phase1_result"]["translated"], "안녕하세요")
|
||||
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):
|
||||
source_text = "这是用于测试长文本分割的句子。" * 120
|
||||
expected_chunks = _split_text(source_text)
|
||||
|
|
@ -170,10 +236,15 @@ class ApiTests(unittest.TestCase):
|
|||
self.assertEqual(mocked.await_count, 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",
|
||||
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))
|
||||
with patch("backend.main.chat_complete", new=phase3_mock):
|
||||
response = self.client.post(f"/api/translate/{session_id}/phase3")
|
||||
|
|
@ -201,8 +272,18 @@ class ApiTests(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):
|
||||
store = SessionStore()
|
||||
store = self.store
|
||||
session_id = store.create(TranslationSession(source_text="secret"), "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")
|
||||
|
||||
def test_stale_phase_update_is_rejected(self):
|
||||
store = SessionStore()
|
||||
store = self.store
|
||||
session_id = store.create(TranslationSession(source_text="first"), "alice")
|
||||
_, version = store.get_with_version(session_id, "alice")
|
||||
store.update(session_id, {"source_text": "second"}, "alice")
|
||||
|
|
@ -226,7 +307,7 @@ class SessionStoreTests(unittest.TestCase):
|
|||
)
|
||||
|
||||
def test_progress_does_not_change_session_version(self):
|
||||
store = SessionStore()
|
||||
store = self.store
|
||||
session_id = store.create(TranslationSession(source_text="first"), "alice")
|
||||
_, version = store.get_with_version(session_id, "alice")
|
||||
|
||||
|
|
@ -247,6 +328,135 @@ class SessionStoreTests(unittest.TestCase):
|
|||
self.assertEqual(current_version, version)
|
||||
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__":
|
||||
unittest.main()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue