From 104577c82611b3b93986ffe51669bceec9af2bd7 Mon Sep 17 00:00:00 2001 From: burnintuna Date: Fri, 31 Jul 2026 02:39:00 +0900 Subject: [PATCH] Add persistent multi-user sessions and Docker deployment --- .dockerignore | 23 + .env.example | 11 +- .gitignore | 6 + AGENTS.md | 18 +- DEPLOYMENT.md | 95 +++ Dockerfile | 39 ++ README.md | 15 + backend/auth.py | 217 +++++-- backend/database.py | 459 +++++++++++++++ backend/main.py | 261 ++++++++- backend/models.py | 60 +- backend/sessions.py | 150 ++--- compose.yml | 41 ++ config/users.docker.json | 1 + frontend/src/App.vue | 14 +- frontend/src/api.ts | 62 +- frontend/src/components/LLMSelector.vue | 4 +- frontend/src/components/ProperNounsEditor.vue | 5 +- frontend/src/stores/auth.ts | 7 +- frontend/src/stores/translation.ts | 547 ++++++++++-------- frontend/src/types.ts | 36 +- frontend/src/views/AdminView.vue | 126 ++++ frontend/src/views/TranslatorView.vue | 95 ++- tests/test_backend.py | 270 ++++++++- 24 files changed, 2094 insertions(+), 468 deletions(-) create mode 100644 .dockerignore create mode 100644 DEPLOYMENT.md create mode 100644 Dockerfile create mode 100644 backend/database.py create mode 100644 compose.yml create mode 100644 config/users.docker.json create mode 100644 frontend/src/views/AdminView.vue diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..72809ae --- /dev/null +++ b/.dockerignore @@ -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* diff --git a/.env.example b/.env.example index 330d923..83a750b 100644 --- a/.env.example +++ b/.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 diff --git a/.gitignore b/.gitignore index 18c2811..9400048 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,12 @@ __pycache__/ # Build output frontend/dist/ +# Persistent application data +data/ +*.db +*.db-shm +*.db-wal + # Env files .env config/.jwt-secret diff --git a/AGENTS.md b/AGENTS.md index 818a716..97648f7 100644 --- a/AGENTS.md +++ b/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 ` + + diff --git a/frontend/src/views/TranslatorView.vue b/frontend/src/views/TranslatorView.vue index 4c0d4db..05b4667 100644 --- a/frontend/src/views/TranslatorView.vue +++ b/frontend/src/views/TranslatorView.vue @@ -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() +}