Improve translation reliability and progress feedback
This commit is contained in:
parent
9439858b11
commit
51a6e845d1
20 changed files with 1194 additions and 216 deletions
|
|
@ -1,21 +1,62 @@
|
|||
"""JWT-based authentication with config-file user management."""
|
||||
|
||||
import os
|
||||
import logging
|
||||
import secrets
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import bcrypt
|
||||
import jwt
|
||||
from dotenv import load_dotenv
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
|
||||
# ── Config ────────────────────────────────────────────
|
||||
|
||||
JWT_SECRET_KEY = os.getenv("JWT_SECRET_KEY", "dev-secret-key-change-in-production")
|
||||
PROJECT_ROOT = Path(__file__).parent.parent
|
||||
load_dotenv(PROJECT_ROOT / ".env")
|
||||
|
||||
|
||||
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"))
|
||||
)
|
||||
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
|
||||
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()
|
||||
security = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
# ── User Loading ───────────────────────────────────────
|
||||
|
|
@ -74,12 +115,20 @@ def decode_access_token(token: str) -> dict | None:
|
|||
|
||||
# ── Dependency ───────────────────────────────────────
|
||||
|
||||
async def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)) -> str:
|
||||
async def get_current_user(
|
||||
credentials: HTTPAuthorizationCredentials | None = Depends(security),
|
||||
) -> str:
|
||||
"""Extract and validate JWT, returning the user_id."""
|
||||
if credentials is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="인증이 필요합니다",
|
||||
)
|
||||
payload = decode_access_token(credentials.credentials)
|
||||
if payload is None or "user_id" not in payload:
|
||||
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:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="유효하지 않거나 만료된 토큰입니다",
|
||||
)
|
||||
return payload["user_id"]
|
||||
return user_id
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from collections.abc import Awaitable, Callable
|
||||
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
|
|
@ -22,6 +23,7 @@ def _load_llm_configs() -> list[LLMConfig]:
|
|||
|
||||
_llm_configs: list[LLMConfig] = []
|
||||
_clients: dict[str, AsyncOpenAI] = {} # alias -> client instance
|
||||
LLM_TIMEOUT_SECONDS = float(os.getenv("LLM_TIMEOUT_SECONDS", "180"))
|
||||
|
||||
|
||||
def get_llm_configs() -> list[LLMConfig]:
|
||||
|
|
@ -46,7 +48,8 @@ def get_client_for_alias(alias: str) -> tuple[AsyncOpenAI, LLMConfig]:
|
|||
client = AsyncOpenAI(
|
||||
api_key=cfg.api_key,
|
||||
base_url=cfg.base_url,
|
||||
timeout=120.0,
|
||||
timeout=LLM_TIMEOUT_SECONDS,
|
||||
max_retries=0,
|
||||
)
|
||||
_clients[alias] = client
|
||||
|
||||
|
|
@ -58,21 +61,33 @@ async def chat_complete(
|
|||
system_prompt: str,
|
||||
user_content: str,
|
||||
temperature: float = 0.3,
|
||||
on_delta: Callable[[str], Awaitable[None]] | None = None,
|
||||
) -> str:
|
||||
"""Send a single-turn chat completion and return the assistant message."""
|
||||
client, cfg = get_client_for_alias(alias)
|
||||
|
||||
response = await client.chat.completions.create(
|
||||
model=cfg.model,
|
||||
messages=[
|
||||
request = {
|
||||
"model": cfg.model,
|
||||
"messages": [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": user_content},
|
||||
],
|
||||
temperature=temperature,
|
||||
)
|
||||
"temperature": temperature,
|
||||
}
|
||||
if on_delta is None:
|
||||
response = await client.chat.completions.create(**request)
|
||||
content = response.choices[0].message.content or ""
|
||||
return content.strip()
|
||||
|
||||
content = response.choices[0].message.content or ""
|
||||
return content.strip()
|
||||
stream = await client.chat.completions.create(**request, stream=True)
|
||||
parts: list[str] = []
|
||||
async for event in stream:
|
||||
if not event.choices:
|
||||
continue
|
||||
delta = event.choices[0].delta.content or ""
|
||||
if delta:
|
||||
parts.append(delta)
|
||||
await on_delta(delta)
|
||||
return "".join(parts).strip()
|
||||
|
||||
|
||||
def get_context_size(alias: str) -> int | None:
|
||||
|
|
|
|||
543
backend/main.py
543
backend/main.py
|
|
@ -1,12 +1,17 @@
|
|||
"""FastAPI application — main entry point for the LLM Translator backend."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
from time import monotonic
|
||||
|
||||
from fastapi import Depends, FastAPI, HTTPException
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel
|
||||
from openai import APITimeoutError, OpenAIError
|
||||
from pydantic import ValidationError
|
||||
|
||||
from backend import auth as auth_mod
|
||||
from backend.auth import get_current_user
|
||||
|
|
@ -24,22 +29,27 @@ from backend.models import (
|
|||
ProperNoun,
|
||||
SessionResponse,
|
||||
TokenResponse,
|
||||
TranslationChunk,
|
||||
UserInfo,
|
||||
TranslationSession,
|
||||
)
|
||||
from backend.prompts import (
|
||||
SYSTEM_PROMPT_PHASE1,
|
||||
SYSTEM_PROMPT_PHASE3,
|
||||
SYSTEM_PROMPT_PHASE4,
|
||||
build_phase1_user_prompt,
|
||||
build_phase3_user_prompt,
|
||||
build_phase4_user_prompt,
|
||||
)
|
||||
from backend.sessions import session_store
|
||||
from backend.sessions import SessionConflictError, session_store
|
||||
|
||||
|
||||
# ── App Setup ────────────────────────────────────────
|
||||
|
||||
app = FastAPI(title="LLM Translator", version="0.1.0")
|
||||
TRANSLATION_CHUNK_CHARS = max(
|
||||
500, int(os.getenv("TRANSLATION_CHUNK_CHARS", "1500"))
|
||||
)
|
||||
|
||||
# CORS (frontend same-origin by default, allow dev origins)
|
||||
app.add_middleware(
|
||||
|
|
@ -57,9 +67,13 @@ app.add_middleware(
|
|||
async def login(req: LoginRequest):
|
||||
import bcrypt as _bcrypt
|
||||
user = auth_mod.get_user_by_username(req.id)
|
||||
if user is None or not _bcrypt.checkpw(
|
||||
req.password.encode("utf-8"), user["password"].encode("utf-8")
|
||||
):
|
||||
try:
|
||||
authenticated = user is not None and _bcrypt.checkpw(
|
||||
req.password.encode("utf-8"), user["password"].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"]})
|
||||
|
|
@ -74,7 +88,7 @@ async def me(user_id: str = Depends(get_current_user)):
|
|||
# ── LLM Config Routes ────────────────────────────────
|
||||
|
||||
@app.get("/api/models")
|
||||
async def list_models():
|
||||
async def list_models(_user_id: str = Depends(get_current_user)):
|
||||
"""Return available LLM model configurations."""
|
||||
configs = get_llm_configs()
|
||||
return [
|
||||
|
|
@ -90,7 +104,10 @@ async def list_models():
|
|||
# ── Session Routes ───────────────────────────────────
|
||||
|
||||
@app.post("/api/translate/create")
|
||||
async def create_session(req: CreateSessionRequest):
|
||||
async def create_session(
|
||||
req: CreateSessionRequest, user_id: str = Depends(get_current_user)
|
||||
):
|
||||
_validate_model_aliases(req.model_dump())
|
||||
session_data = TranslationSession(
|
||||
source_text=req.source_text,
|
||||
source_language=req.source_language,
|
||||
|
|
@ -100,30 +117,71 @@ async def create_session(req: CreateSessionRequest):
|
|||
model_phase3=req.model_phase3,
|
||||
model_phase4=req.model_phase4,
|
||||
)
|
||||
session_id = session_store.create(session_data)
|
||||
session_id = session_store.create(session_data, user_id)
|
||||
return {"session_id": session_id}
|
||||
|
||||
|
||||
@app.get("/api/sessions/{session_id}")
|
||||
async def get_session(session_id: str):
|
||||
data = session_store.get(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()
|
||||
|
||||
|
||||
@app.get("/api/sessions/{session_id}/progress")
|
||||
async def get_session_progress(
|
||||
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 {"progress": data.progress.model_dump() if data.progress else None}
|
||||
|
||||
|
||||
@app.delete("/api/sessions/{session_id}")
|
||||
async def delete_session(session_id: str):
|
||||
deleted = session_store.delete(session_id)
|
||||
async def delete_session(session_id: str, user_id: str = Depends(get_current_user)):
|
||||
deleted = session_store.delete(session_id, user_id)
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail="세션을 찾을 수 없습니다")
|
||||
return {"deleted": True}
|
||||
|
||||
|
||||
@app.patch("/api/sessions/{session_id}")
|
||||
async def update_session(session_id: str, req: PhaseUpdateRequest):
|
||||
async def update_session(
|
||||
session_id: str,
|
||||
req: PhaseUpdateRequest,
|
||||
user_id: str = Depends(get_current_user),
|
||||
):
|
||||
data = req.model_dump(exclude_none=True)
|
||||
updated = session_store.update(session_id, data)
|
||||
_validate_model_aliases(data)
|
||||
current = session_store.get(session_id, user_id)
|
||||
if current is None:
|
||||
raise HTTPException(status_code=404, detail="세션을 찾을 수 없습니다")
|
||||
|
||||
changed = {key for key, value in data.items() if getattr(current, key) != value}
|
||||
if changed & {"source_text", "source_language", "target_language", "model_phase1"}:
|
||||
data.update({
|
||||
"phase1_result": None,
|
||||
"phase1_chunks": [],
|
||||
"phase2_proper_nouns": [],
|
||||
"phase2_style": "",
|
||||
"phase3_result": "",
|
||||
"phase3_chunks": [],
|
||||
"phase4_result": "",
|
||||
"progress": None,
|
||||
})
|
||||
elif changed & {"model_phase3"}:
|
||||
data.update({
|
||||
"phase3_result": "",
|
||||
"phase3_chunks": [],
|
||||
"phase4_result": "",
|
||||
"progress": None,
|
||||
})
|
||||
elif changed & {"model_phase4"}:
|
||||
data.update({"phase4_result": "", "progress": None})
|
||||
|
||||
updated = session_store.update(session_id, data, user_id)
|
||||
if updated is None:
|
||||
raise HTTPException(status_code=404, detail="세션을 찾을 수 없습니다")
|
||||
return SessionResponse(session_id=session_id, data=updated).model_dump()
|
||||
|
|
@ -132,46 +190,69 @@ async def update_session(session_id: str, req: PhaseUpdateRequest):
|
|||
# ── Phase Execution Routes ───────────────────────────
|
||||
|
||||
@app.post("/api/translate/{session_id}/phase1")
|
||||
async def run_phase1(session_id: str):
|
||||
async def run_phase1(session_id: str, user_id: str = Depends(get_current_user)):
|
||||
"""Run Phase 1: rough translation + proper noun extraction + summary + style analysis."""
|
||||
session = session_store.get(session_id)
|
||||
if session is None:
|
||||
snapshot = session_store.get_with_version(session_id, user_id)
|
||||
if snapshot is None:
|
||||
raise HTTPException(status_code=404, detail="세션을 찾을 수 없습니다")
|
||||
session, version = snapshot
|
||||
|
||||
alias = session.model_phase1
|
||||
if not alias:
|
||||
raise HTTPException(status_code=400, detail="Phase 1 LLM 모델을 선택하세요")
|
||||
if not session.source_text.strip():
|
||||
raise HTTPException(status_code=400, detail="원문을 입력하세요")
|
||||
|
||||
# Context size check (rough: source text tokens ~ len/2 for Korean)
|
||||
ctx_size = get_context_size(alias)
|
||||
if ctx_size and len(session.source_text) > ctx_size * 0.7:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"원문이 너무 깁니다 (최대 {ctx_size} 컨텍스트의 70% 이내로 줄여주세요)",
|
||||
chunk_results: list[tuple[str, Phase1Result]] = []
|
||||
source_chunks = _split_text(session.source_text)
|
||||
for chunk_index, source_chunk in enumerate(source_chunks, start=1):
|
||||
user_prompt = build_phase1_user_prompt(
|
||||
source_chunk, session.source_language, session.target_language
|
||||
)
|
||||
_ensure_context_capacity(alias, f"{SYSTEM_PROMPT_PHASE1}\n{user_prompt}")
|
||||
raw_response = await _chat_complete(
|
||||
alias,
|
||||
SYSTEM_PROMPT_PHASE1,
|
||||
user_prompt,
|
||||
session_id=session_id,
|
||||
user_id=user_id,
|
||||
phase=1,
|
||||
chunk_index=chunk_index,
|
||||
total_chunks=len(source_chunks),
|
||||
status="초벌 번역 수신 중",
|
||||
)
|
||||
chunk_result = await _recover_phase1_response(
|
||||
raw_response,
|
||||
source_chunk,
|
||||
alias,
|
||||
session_id,
|
||||
user_id,
|
||||
chunk_index,
|
||||
len(source_chunks),
|
||||
session.target_language,
|
||||
)
|
||||
chunk_results.append((source_chunk, chunk_result))
|
||||
|
||||
user_prompt = build_phase1_user_prompt(session.source_text, session.target_language)
|
||||
raw_response = await chat_complete(alias, SYSTEM_PROMPT_PHASE1, user_prompt)
|
||||
|
||||
# Parse JSON response — extract from code blocks if wrapped
|
||||
import json
|
||||
cleaned = _extract_json(raw_response)
|
||||
parsed = json.loads(cleaned)
|
||||
|
||||
result = Phase1Result(
|
||||
translated=parsed.get("translated", ""),
|
||||
proper_nouns=[ProperNoun(**pn) for pn in parsed.get("proper_nouns", [])],
|
||||
summary=parsed.get("summary", ""),
|
||||
style=parsed.get("style", ""),
|
||||
)
|
||||
result = _merge_phase1_results([item[1] for item in chunk_results])
|
||||
|
||||
# Update session with Phase 1 results and initialize Phase 2 data
|
||||
update_data = {
|
||||
"phase1_result": result.model_dump(),
|
||||
"phase1_chunks": [
|
||||
TranslationChunk(
|
||||
source_text=source_chunk,
|
||||
translated=chunk_result.translated,
|
||||
).model_dump()
|
||||
for source_chunk, chunk_result in chunk_results
|
||||
],
|
||||
"phase2_proper_nouns": [pn.model_dump() for pn in result.proper_nouns],
|
||||
"phase2_style": result.style,
|
||||
"phase3_result": "",
|
||||
"phase3_chunks": [],
|
||||
"phase4_result": "",
|
||||
"progress": None,
|
||||
}
|
||||
updated = session_store.update(session_id, update_data)
|
||||
updated = _update_phase_result(session_id, update_data, user_id, version)
|
||||
if updated is None:
|
||||
raise HTTPException(status_code=404, detail="세션을 찾을 수 없습니다")
|
||||
|
||||
|
|
@ -179,17 +260,25 @@ async def run_phase1(session_id: str):
|
|||
|
||||
|
||||
@app.post("/api/translate/{session_id}/phase2")
|
||||
async def run_phase2(session_id: str, req: Phase2ConfirmRequest):
|
||||
async def run_phase2(
|
||||
session_id: str,
|
||||
req: Phase2ConfirmRequest,
|
||||
user_id: str = Depends(get_current_user),
|
||||
):
|
||||
"""Phase 2: user confirms or edits proper nouns and style."""
|
||||
session = session_store.get(session_id)
|
||||
session = session_store.get(session_id, user_id)
|
||||
if session is None:
|
||||
raise HTTPException(status_code=404, detail="세션을 찾을 수 없습니다")
|
||||
|
||||
update_data = {
|
||||
"phase2_proper_nouns": [pn.model_dump() for pn in req.proper_nouns],
|
||||
"phase2_style": req.style,
|
||||
"phase3_result": "",
|
||||
"phase3_chunks": [],
|
||||
"phase4_result": "",
|
||||
"progress": None,
|
||||
}
|
||||
updated = session_store.update(session_id, update_data)
|
||||
updated = session_store.update(session_id, update_data, user_id)
|
||||
if updated is None:
|
||||
raise HTTPException(status_code=404, detail="세션을 찾을 수 없습니다")
|
||||
|
||||
|
|
@ -197,27 +286,55 @@ async def run_phase2(session_id: str, req: Phase2ConfirmRequest):
|
|||
|
||||
|
||||
@app.post("/api/translate/{session_id}/phase3")
|
||||
async def run_phase3(session_id: str):
|
||||
async def run_phase3(session_id: str, user_id: str = Depends(get_current_user)):
|
||||
"""Run Phase 3: re-translation with proper noun and style constraints."""
|
||||
session = session_store.get(session_id)
|
||||
if session is None:
|
||||
snapshot = session_store.get_with_version(session_id, user_id)
|
||||
if snapshot is None:
|
||||
raise HTTPException(status_code=404, detail="세션을 찾을 수 없습니다")
|
||||
session, version = snapshot
|
||||
|
||||
if not session.phase1_result:
|
||||
raise HTTPException(status_code=400, detail="먼저 Phase 1을 실행하세요")
|
||||
|
||||
alias = session.model_phase3 or session.model_phase1
|
||||
user_prompt = build_phase3_user_prompt(
|
||||
phase1_translated=session.phase1_result.translated,
|
||||
proper_nouns=[pn.model_dump() for pn in session.phase2_proper_nouns],
|
||||
style=session.phase2_style or (session.phase1_result.style if session.phase1_result else ""),
|
||||
target_language=session.target_language,
|
||||
)
|
||||
phase1_chunks = session.phase1_chunks or [
|
||||
TranslationChunk(
|
||||
source_text=session.source_text,
|
||||
translated=session.phase1_result.translated,
|
||||
)
|
||||
]
|
||||
result_chunks: list[str] = []
|
||||
for chunk_index, chunk in enumerate(phase1_chunks, start=1):
|
||||
user_prompt = build_phase3_user_prompt(
|
||||
source_text=chunk.source_text,
|
||||
phase1_translated=chunk.translated,
|
||||
proper_nouns=[pn.model_dump() for pn in session.phase2_proper_nouns],
|
||||
style=session.phase2_style or session.phase1_result.style,
|
||||
target_language=session.target_language,
|
||||
)
|
||||
_ensure_context_capacity(alias, f"{SYSTEM_PROMPT_PHASE3}\n{user_prompt}")
|
||||
chunk_result = await _chat_complete(
|
||||
alias,
|
||||
SYSTEM_PROMPT_PHASE3,
|
||||
user_prompt,
|
||||
session_id=session_id,
|
||||
user_id=user_id,
|
||||
phase=3,
|
||||
chunk_index=chunk_index,
|
||||
total_chunks=len(phase1_chunks),
|
||||
status="재번역 수신 중",
|
||||
)
|
||||
if not chunk_result:
|
||||
raise HTTPException(status_code=502, detail="LLM이 재번역 결과를 반환하지 않았습니다")
|
||||
result_chunks.append(chunk_result)
|
||||
|
||||
result_text = await chat_complete(alias, SYSTEM_PROMPT_PHASE3, user_prompt)
|
||||
|
||||
update_data = {"phase3_result": result_text}
|
||||
updated = session_store.update(session_id, update_data)
|
||||
update_data = {
|
||||
"phase3_result": "\n\n".join(result_chunks),
|
||||
"phase3_chunks": result_chunks,
|
||||
"phase4_result": "",
|
||||
"progress": None,
|
||||
}
|
||||
updated = _update_phase_result(session_id, update_data, user_id, version)
|
||||
if updated is None:
|
||||
raise HTTPException(status_code=404, detail="세션을 찾을 수 없습니다")
|
||||
|
||||
|
|
@ -225,29 +342,50 @@ async def run_phase3(session_id: str):
|
|||
|
||||
|
||||
@app.post("/api/translate/{session_id}/phase4")
|
||||
async def run_phase4(session_id: str):
|
||||
async def run_phase4(session_id: str, user_id: str = Depends(get_current_user)):
|
||||
"""Run Phase 4: polish for readability."""
|
||||
session = session_store.get(session_id)
|
||||
if session is None:
|
||||
snapshot = session_store.get_with_version(session_id, user_id)
|
||||
if snapshot is None:
|
||||
raise HTTPException(status_code=404, detail="세션을 찾을 수 없습니다")
|
||||
session, version = snapshot
|
||||
|
||||
if not session.phase3_result:
|
||||
raise HTTPException(status_code=400, detail="먼저 Phase 3을 실행하세요")
|
||||
|
||||
alias = session.model_phase4 or session.model_phase3 or session.model_phase1
|
||||
|
||||
user_prompt = build_phase4_user_prompt(
|
||||
phase3_result=session.phase3_result,
|
||||
proper_nouns=[pn.model_dump() for pn in session.phase2_proper_nouns],
|
||||
style=session.phase2_style or (session.phase1_result.style if session.phase1_result else ""),
|
||||
)
|
||||
|
||||
# Patch target_language into system prompt
|
||||
source_chunks = session.phase3_chunks or _split_text(session.phase3_result)
|
||||
result_chunks: list[str] = []
|
||||
sys_prompt = SYSTEM_PROMPT_PHASE4.replace("{target_language}", session.target_language)
|
||||
result_text = await chat_complete(alias, sys_prompt, user_prompt)
|
||||
for chunk_index, source_chunk in enumerate(source_chunks, start=1):
|
||||
user_prompt = build_phase4_user_prompt(
|
||||
phase3_result=source_chunk,
|
||||
proper_nouns=[pn.model_dump() for pn in session.phase2_proper_nouns],
|
||||
style=session.phase2_style or (
|
||||
session.phase1_result.style if session.phase1_result else ""
|
||||
),
|
||||
target_language=session.target_language,
|
||||
)
|
||||
_ensure_context_capacity(alias, f"{sys_prompt}\n{user_prompt}")
|
||||
chunk_result = await _chat_complete(
|
||||
alias,
|
||||
sys_prompt,
|
||||
user_prompt,
|
||||
session_id=session_id,
|
||||
user_id=user_id,
|
||||
phase=4,
|
||||
chunk_index=chunk_index,
|
||||
total_chunks=len(source_chunks),
|
||||
status="마무리 번역 수신 중",
|
||||
)
|
||||
if not chunk_result:
|
||||
raise HTTPException(status_code=502, detail="LLM이 마무리 결과를 반환하지 않았습니다")
|
||||
result_chunks.append(chunk_result)
|
||||
|
||||
update_data = {"phase4_result": result_text}
|
||||
updated = session_store.update(session_id, update_data)
|
||||
update_data = {
|
||||
"phase4_result": "\n\n".join(result_chunks),
|
||||
"progress": None,
|
||||
}
|
||||
updated = _update_phase_result(session_id, update_data, user_id, version)
|
||||
if updated is None:
|
||||
raise HTTPException(status_code=404, detail="세션을 찾을 수 없습니다")
|
||||
|
||||
|
|
@ -256,19 +394,276 @@ async def run_phase4(session_id: str):
|
|||
|
||||
# ── Helpers ───────────────────────────────────────────
|
||||
|
||||
def _split_text(text: str) -> list[str]:
|
||||
"""Split long text near sentence or line boundaries for reliable LLM calls."""
|
||||
remaining = text.strip()
|
||||
chunks: list[str] = []
|
||||
boundary_chars = "\n。!?!?;;."
|
||||
|
||||
while len(remaining) > TRANSLATION_CHUNK_CHARS:
|
||||
window = remaining[:TRANSLATION_CHUNK_CHARS]
|
||||
minimum_boundary = TRANSLATION_CHUNK_CHARS // 2
|
||||
split_at = max(window.rfind(char) for char in boundary_chars)
|
||||
if split_at < minimum_boundary:
|
||||
whitespace_at = max(window.rfind(" "), window.rfind("\t"))
|
||||
split_at = (
|
||||
whitespace_at + 1
|
||||
if whitespace_at >= minimum_boundary
|
||||
else TRANSLATION_CHUNK_CHARS
|
||||
)
|
||||
else:
|
||||
split_at += 1
|
||||
chunks.append(remaining[:split_at].strip())
|
||||
remaining = remaining[split_at:].lstrip()
|
||||
|
||||
if remaining:
|
||||
chunks.append(remaining)
|
||||
return chunks
|
||||
|
||||
|
||||
def _parse_phase1_response(raw_response: str) -> Phase1Result | None:
|
||||
extracted = _extract_json(raw_response)
|
||||
candidates = [extracted, re.sub(r",\s*([}\]])", r"\1", extracted)]
|
||||
for candidate in candidates:
|
||||
try:
|
||||
parsed = json.loads(candidate)
|
||||
if not isinstance(parsed, dict):
|
||||
continue
|
||||
translated = parsed.get("translated") or parsed.get("translation") or ""
|
||||
proper_nouns = []
|
||||
for item in parsed.get("proper_nouns") or []:
|
||||
if not isinstance(item, dict) or not item.get("original"):
|
||||
continue
|
||||
proper_nouns.append(
|
||||
ProperNoun(
|
||||
original=str(item["original"]),
|
||||
suggested=str(
|
||||
item.get("suggested") or item.get("final") or item["original"]
|
||||
),
|
||||
final=str(item.get("final") or ""),
|
||||
)
|
||||
)
|
||||
result = Phase1Result(
|
||||
translated=str(translated),
|
||||
proper_nouns=proper_nouns,
|
||||
summary=str(parsed.get("summary") or ""),
|
||||
style=str(parsed.get("style") or ""),
|
||||
)
|
||||
if result.translated.strip():
|
||||
return result
|
||||
except (json.JSONDecodeError, TypeError, ValueError, ValidationError):
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
async def _recover_phase1_response(
|
||||
raw_response: str,
|
||||
source_text: str,
|
||||
alias: str,
|
||||
session_id: str,
|
||||
user_id: str,
|
||||
chunk_index: int,
|
||||
total_chunks: int,
|
||||
target_language: str,
|
||||
) -> Phase1Result:
|
||||
parsed = _parse_phase1_response(raw_response)
|
||||
if parsed is not None:
|
||||
return parsed
|
||||
|
||||
repair_system = """\
|
||||
당신은 손상된 JSON을 복구하는 도구입니다. 내용은 번역하거나 요약하지 말고, 주어진 응답의 내용을 보존하여 순수 JSON 객체만 반환하세요.\
|
||||
"""
|
||||
repair_prompt = f"""\
|
||||
다음 응답을 translated, proper_nouns, summary, style 필드를 가진 유효한 JSON으로 복구하세요.
|
||||
proper_nouns의 각 항목은 original과 suggested 필드를 가져야 합니다.
|
||||
|
||||
손상된 응답:
|
||||
---
|
||||
{raw_response}
|
||||
---"""
|
||||
repaired_response = await _chat_complete(
|
||||
alias,
|
||||
repair_system,
|
||||
repair_prompt,
|
||||
temperature=0.0,
|
||||
session_id=session_id,
|
||||
user_id=user_id,
|
||||
phase=1,
|
||||
chunk_index=chunk_index,
|
||||
total_chunks=total_chunks,
|
||||
status="JSON 응답 복구 중",
|
||||
)
|
||||
parsed = _parse_phase1_response(repaired_response)
|
||||
if parsed is not None:
|
||||
parsed.warnings.append("LLM의 JSON 응답을 자동으로 복구했습니다.")
|
||||
return parsed
|
||||
|
||||
fallback_prompt = f"""\
|
||||
다음 원문을 "{target_language}"으로 정확하게 번역하세요.
|
||||
설명이나 JSON 없이 번역문만 반환하세요.
|
||||
|
||||
원문:
|
||||
---
|
||||
{source_text}
|
||||
---"""
|
||||
translated = await _chat_complete(
|
||||
alias,
|
||||
SYSTEM_PROMPT_PHASE1,
|
||||
fallback_prompt,
|
||||
session_id=session_id,
|
||||
user_id=user_id,
|
||||
phase=1,
|
||||
chunk_index=chunk_index,
|
||||
total_chunks=total_chunks,
|
||||
status="일반 번역으로 전환 중",
|
||||
)
|
||||
if not translated:
|
||||
raise HTTPException(status_code=502, detail="LLM이 번역 결과를 반환하지 않았습니다")
|
||||
return Phase1Result(
|
||||
translated=translated,
|
||||
proper_nouns=[],
|
||||
summary="",
|
||||
style="",
|
||||
warnings=[
|
||||
"JSON 응답을 복구하지 못해 일반 번역으로 대체했습니다. "
|
||||
"이 조각의 고유명사, 요약, 문체 정보는 비어 있습니다."
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def _merge_phase1_results(results: list[Phase1Result]) -> Phase1Result:
|
||||
proper_nouns: list[ProperNoun] = []
|
||||
seen_originals: set[str] = set()
|
||||
for result in results:
|
||||
for proper_noun in result.proper_nouns:
|
||||
key = proper_noun.original.strip()
|
||||
if key and key not in seen_originals:
|
||||
proper_nouns.append(proper_noun)
|
||||
seen_originals.add(key)
|
||||
|
||||
styles = [result.style for result in results if result.style]
|
||||
style = Counter(styles).most_common(1)[0][0] if styles else ""
|
||||
return Phase1Result(
|
||||
translated="\n\n".join(result.translated for result in results),
|
||||
proper_nouns=proper_nouns,
|
||||
summary="\n".join(result.summary for result in results if result.summary),
|
||||
style=style,
|
||||
warnings=[warning for result in results for warning in result.warnings],
|
||||
)
|
||||
|
||||
|
||||
def _validate_model_aliases(data: dict) -> None:
|
||||
available = {config.alias for config in get_llm_configs()}
|
||||
invalid = {
|
||||
value
|
||||
for key, value in data.items()
|
||||
if key.startswith("model_phase") and value and value not in available
|
||||
}
|
||||
if invalid:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"알 수 없는 LLM 모델입니다: {', '.join(sorted(invalid))}",
|
||||
)
|
||||
|
||||
|
||||
async def _chat_complete(
|
||||
alias: str,
|
||||
system_prompt: str,
|
||||
user_prompt: str,
|
||||
temperature: float = 0.3,
|
||||
*,
|
||||
session_id: str | None = None,
|
||||
user_id: str | None = None,
|
||||
phase: int = 0,
|
||||
chunk_index: int = 1,
|
||||
total_chunks: int = 1,
|
||||
status: str = "응답 수신 중",
|
||||
) -> str:
|
||||
parts: list[str] = []
|
||||
last_progress_update = 0.0
|
||||
|
||||
def set_progress(progress_status: str, preview: str = "") -> None:
|
||||
if session_id and user_id:
|
||||
session_store.set_progress(
|
||||
session_id,
|
||||
{
|
||||
"phase": phase,
|
||||
"chunk": chunk_index,
|
||||
"total_chunks": total_chunks,
|
||||
"status": progress_status,
|
||||
"preview": preview[-3000:],
|
||||
},
|
||||
user_id,
|
||||
)
|
||||
|
||||
async def on_delta(delta: str) -> None:
|
||||
nonlocal last_progress_update
|
||||
parts.append(delta)
|
||||
now = monotonic()
|
||||
if now - last_progress_update >= 0.25:
|
||||
set_progress(status, "".join(parts))
|
||||
last_progress_update = now
|
||||
|
||||
set_progress("LLM 응답 대기 중")
|
||||
try:
|
||||
result = await chat_complete(
|
||||
alias,
|
||||
system_prompt,
|
||||
user_prompt,
|
||||
temperature=temperature,
|
||||
on_delta=on_delta if session_id and user_id else None,
|
||||
)
|
||||
set_progress(f"{chunk_index}/{total_chunks} 조각 수신 완료", result)
|
||||
return result
|
||||
except APITimeoutError as exc:
|
||||
set_progress("응답 시간 초과", "".join(parts))
|
||||
raise HTTPException(status_code=504, detail="LLM 서버 응답 시간이 초과되었습니다") from exc
|
||||
except ValueError as exc:
|
||||
set_progress("요청 오류", "".join(parts))
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except OpenAIError as exc:
|
||||
set_progress("LLM 서버 요청 실패", "".join(parts))
|
||||
raise HTTPException(status_code=502, detail="LLM 서버 요청에 실패했습니다") from exc
|
||||
|
||||
|
||||
def _update_phase_result(
|
||||
session_id: str, data: dict, user_id: str, expected_version: int
|
||||
) -> TranslationSession | None:
|
||||
try:
|
||||
return session_store.update(
|
||||
session_id, data, user_id, expected_version=expected_version
|
||||
)
|
||||
except SessionConflictError as exc:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="처리 중 세션이 변경되었습니다. 현재 상태에서 단계를 다시 실행하세요",
|
||||
) from exc
|
||||
|
||||
|
||||
def _ensure_context_capacity(alias: str, content: str) -> None:
|
||||
context_size = get_context_size(alias)
|
||||
if context_size and len(content) > context_size * 0.7:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"입력이 너무 깁니다 ({context_size} 컨텍스트의 70% 이내로 줄여주세요)",
|
||||
)
|
||||
|
||||
|
||||
def _extract_json(text: str) -> str:
|
||||
"""Extract JSON from LLM response, handling markdown code blocks."""
|
||||
"""Extract a likely JSON object from code fences or surrounding prose."""
|
||||
text = text.strip()
|
||||
# Remove markdown json/code block wrapper
|
||||
if "```" in text:
|
||||
parts = text.split("```")
|
||||
for part in parts:
|
||||
part = part.strip()
|
||||
if part.startswith("json"):
|
||||
if part.lower().startswith("json"):
|
||||
part = part[4:].strip()
|
||||
if part.startswith("{"):
|
||||
return part
|
||||
return text
|
||||
if "{" in part and "}" in part:
|
||||
text = part
|
||||
break
|
||||
start = text.find("{")
|
||||
end = text.rfind("}")
|
||||
return text[start : end + 1] if start >= 0 and end > start else text
|
||||
|
||||
|
||||
# ── Static File Serving (Frontend) ───────────────────
|
||||
|
|
|
|||
|
|
@ -42,6 +42,20 @@ class Phase1Result(BaseModel):
|
|||
proper_nouns: list[ProperNoun] # 추출된 고유명사 리스트
|
||||
summary: str # 내용 요약
|
||||
style: str # 문체 분석 결과
|
||||
warnings: list[str] = [] # JSON 복구/폴백 등 품질 관련 알림
|
||||
|
||||
|
||||
class TranslationChunk(BaseModel):
|
||||
source_text: str
|
||||
translated: str
|
||||
|
||||
|
||||
class PhaseProgress(BaseModel):
|
||||
phase: int
|
||||
chunk: int
|
||||
total_chunks: int
|
||||
status: str
|
||||
preview: str = ""
|
||||
|
||||
|
||||
class TranslationSession(BaseModel):
|
||||
|
|
@ -55,10 +69,13 @@ 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에서 최종 확정된 고유명사
|
||||
phase2_style: str = "" # Phase 2에서 최종 확정된 문체
|
||||
phase3_result: str = "" # 재번역 결과
|
||||
phase3_chunks: list[str] = [] # 장문 분할 재번역 결과
|
||||
phase4_result: str = "" # 마무리 다듬기 결과
|
||||
progress: PhaseProgress | None = None # 장기 작업 진행 상태
|
||||
|
||||
|
||||
# ── API Requests ───────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -5,13 +5,21 @@
|
|||
|
||||
SYSTEM_PROMPT_PHASE1 = """\
|
||||
당신은 전문 번역가입니다. 사용자의 지시에 따라 원문을 도착 언어로 번역하고, \
|
||||
고유명사를 추출하며, 내용 요약을 작성하고, 문체를 분석합니다.\
|
||||
고유명사를 추출하며, 내용 요약을 작성하고, 문체를 분석합니다. 번역할 때 원문의 \
|
||||
주어와 목적어, 인칭, 시제, 양태, 높임 수준을 정확히 보존합니다.\
|
||||
"""
|
||||
|
||||
|
||||
def build_phase1_user_prompt(source_text: str, target_language: str) -> str:
|
||||
def build_phase1_user_prompt(
|
||||
source_text: str, source_language: str, target_language: str
|
||||
) -> str:
|
||||
source_instruction = (
|
||||
"원문의 언어를 자동으로 판단하고"
|
||||
if source_language == "auto"
|
||||
else f'원문이 "{source_language}"임을 전제로'
|
||||
)
|
||||
return f"""\
|
||||
다음 원문을 "{target_language}"으로 번역하세요.
|
||||
{source_instruction} 다음 원문을 "{target_language}"으로 번역하세요.
|
||||
|
||||
번역 결과를 반드시 아래 JSON 포맷으로만 반환하세요 (마크다운 코드 블록 없이 순수 JSON):
|
||||
|
||||
|
|
@ -44,13 +52,14 @@ SYSTEM_PROMPT_PHASE3 = """\
|
|||
|
||||
|
||||
def build_phase3_user_prompt(
|
||||
source_text: str,
|
||||
phase1_translated: str,
|
||||
proper_nouns: list[dict],
|
||||
style: str,
|
||||
target_language: str,
|
||||
) -> str:
|
||||
pn_lines = "\n".join(
|
||||
f" - {pn['original']} → {pn.get('final', pn.get('suggested', ''))}"
|
||||
f" - {pn['original']} → {pn.get('final') or pn.get('suggested', '')}"
|
||||
for pn in proper_nouns if pn.get("original") or pn.get("suggested")
|
||||
)
|
||||
|
||||
|
|
@ -65,10 +74,15 @@ def build_phase3_user_prompt(
|
|||
재번역 규칙:
|
||||
1. 위 고유명사 매핑을 반드시 준수하세요
|
||||
2. 지시된 문체에 맞게 어조를 조정하세요
|
||||
3. 초벌 번역의 의미는 유지하되 더 자연스럽고 정확히 다듬으세요
|
||||
3. 원문을 기준으로 초벌 번역의 누락이나 오역을 바로잡으세요
|
||||
4. 결과만 반환하세요 (설명 없이 번역문만)
|
||||
|
||||
초벌 번역:
|
||||
원문:
|
||||
---
|
||||
{source_text}
|
||||
---
|
||||
|
||||
초벌 번역 참고본:
|
||||
---
|
||||
{phase1_translated}
|
||||
---"""
|
||||
|
|
@ -78,7 +92,8 @@ def build_phase3_user_prompt(
|
|||
|
||||
SYSTEM_PROMPT_PHASE4 = """\
|
||||
당신은 "{target_language}" 원어민 편집자입니다. 제공된 번역문을 \
|
||||
"{target_language}" 화자에게 읽기 편하도록 다듬으세요.\
|
||||
"{target_language}" 화자에게 읽기 편하도록 다듬되, 번역문의 의미 관계와 \
|
||||
인칭, 시제, 양태, 높임 수준은 변경하지 마세요.\
|
||||
"""
|
||||
|
||||
|
||||
|
|
@ -86,9 +101,10 @@ def build_phase4_user_prompt(
|
|||
phase3_result: str,
|
||||
proper_nouns: list[dict],
|
||||
style: str,
|
||||
target_language: str,
|
||||
) -> str:
|
||||
pn_lines = "\n".join(
|
||||
f" - {pn['original']} → {pn.get('final', pn.get('suggested', ''))}"
|
||||
f" - {pn['original']} → {pn.get('final') or pn.get('suggested', '')}"
|
||||
for pn in proper_nouns if pn.get("original") or pn.get("suggested")
|
||||
)
|
||||
|
||||
|
|
@ -102,10 +118,12 @@ def build_phase4_user_prompt(
|
|||
|
||||
다듬기 규칙:
|
||||
1. 고유명사는 절대 변경하지 마세요
|
||||
2. 한국어 화자에게 자연스럽게 읽히도록 어순과 표현을 조정하세요
|
||||
2. {target_language} 화자에게 자연스럽게 읽히도록 어순과 표현을 조정하세요
|
||||
3. 문장은 간결하고 명확하게 다듬으세요
|
||||
4. 의미는 반드시 유지하세요
|
||||
5. 결과만 반환하세요 (설명 없이 번역문만)
|
||||
5. 주어와 목적어, 화자와 청자의 관계를 뒤바꾸지 마세요
|
||||
6. 인칭, 시제, 희망·추측 등의 양태와 높임 수준을 변경하지 마세요
|
||||
7. 결과만 반환하세요 (설명 없이 번역문만)
|
||||
|
||||
번역문:
|
||||
---
|
||||
|
|
|
|||
|
|
@ -7,8 +7,12 @@ from typing import Any
|
|||
from backend.models import TranslationSession
|
||||
|
||||
|
||||
class SessionConflictError(Exception):
|
||||
"""Raised when a long-running phase tries to update a changed session."""
|
||||
|
||||
|
||||
class SessionStore:
|
||||
"""Thread-safe-ish in-memory dict for translation sessions.
|
||||
"""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.
|
||||
|
|
@ -18,18 +22,26 @@ class SessionStore:
|
|||
self._store: dict[str, dict[str, Any]] = {}
|
||||
self.ttl_hours = ttl_hours
|
||||
|
||||
def create(self, session_data: TranslationSession) -> str:
|
||||
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
|
||||
|
||||
def get(self, session_id: str) -> TranslationSession | None:
|
||||
def get(self, session_id: str, owner_id: str) -> TranslationSession | None:
|
||||
result = self.get_with_version(session_id, owner_id)
|
||||
return result[0] if result else None
|
||||
|
||||
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:
|
||||
if entry is None or entry["owner_id"] != owner_id:
|
||||
return None
|
||||
# Check TTL
|
||||
now = datetime.now(timezone.utc)
|
||||
|
|
@ -39,22 +51,51 @@ class SessionStore:
|
|||
return None
|
||||
# Touch (refresh TTL)
|
||||
entry["updated_at"] = now
|
||||
return TranslationSession(**entry["data"])
|
||||
return TranslationSession(**entry["data"]), entry["version"]
|
||||
|
||||
def update(self, session_id: str, data: dict[str, Any]) -> TranslationSession | None:
|
||||
def update(
|
||||
self,
|
||||
session_id: str,
|
||||
data: dict[str, Any],
|
||||
owner_id: str,
|
||||
expected_version: int | None = None,
|
||||
) -> TranslationSession | None:
|
||||
entry = self._store.get(session_id)
|
||||
if entry is None:
|
||||
if entry is None or entry["owner_id"] != owner_id:
|
||||
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["updated_at"] = datetime.now(timezone.utc)
|
||||
entry["version"] += 1
|
||||
entry["updated_at"] = now
|
||||
return TranslationSession(**entry["data"])
|
||||
|
||||
def delete(self, session_id: str) -> bool:
|
||||
if session_id in self._store:
|
||||
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
|
||||
|
||||
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
|
||||
return True
|
||||
|
||||
def cleanup_expired(self):
|
||||
"""Remove all expired sessions."""
|
||||
now = datetime.now(timezone.utc)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue