Add persistent multi-user sessions and Docker deployment

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

View file

@ -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(