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
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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue