동의 게이트와 런타임 안정화
This commit is contained in:
parent
0eb7d925ed
commit
0ec266a761
34 changed files with 1186 additions and 158 deletions
|
|
@ -25,7 +25,14 @@ from fastapi import APIRouter, Cookie, HTTPException, Query, Request, Response,
|
|||
from fastapi.responses import RedirectResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ..auth_sessions import InactiveUserError, SessionUser, create_session, revoke_session
|
||||
from ..auth_sessions import (
|
||||
InactiveUserError,
|
||||
SessionUser,
|
||||
create_session,
|
||||
record_user_consent,
|
||||
revoke_session,
|
||||
withdraw_user_consent,
|
||||
)
|
||||
from ..config import settings
|
||||
from ..deps import CurrentPrincipal, Principal, Role
|
||||
from ..saml import (
|
||||
|
|
@ -71,6 +78,15 @@ class MeResponse(BaseModel):
|
|||
display_name: str
|
||||
role: str
|
||||
cohort_ids: list[str]
|
||||
consent_at: float | None = None
|
||||
|
||||
|
||||
class ConsentRequest(BaseModel):
|
||||
accepted: bool = True
|
||||
|
||||
|
||||
class ConsentResponse(BaseModel):
|
||||
consent_at: float | None = None
|
||||
|
||||
|
||||
class AuthProviderStatus(BaseModel):
|
||||
|
|
@ -564,6 +580,7 @@ def _me_response(user: SessionUser | Principal) -> MeResponse:
|
|||
display_name=getattr(user, "display_name", "") or getattr(user, "email", ""),
|
||||
role=user.role.value if isinstance(user.role, Role) else user.role,
|
||||
cohort_ids=user.cohort_ids,
|
||||
consent_at=getattr(user, "consent_at", None),
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -933,6 +950,35 @@ async def logout(
|
|||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/consent", response_model=ConsentResponse)
|
||||
async def accept_consent(
|
||||
body: ConsentRequest,
|
||||
principal: CurrentPrincipal,
|
||||
) -> ConsentResponse:
|
||||
"""Record the current learner's practice-session consent receipt."""
|
||||
if principal.role != Role.LEARNER:
|
||||
raise HTTPException(status.HTTP_403_FORBIDDEN, detail="learner consent only")
|
||||
if not body.accepted:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="consent_not_accepted")
|
||||
consent_at = await record_user_consent(principal.user_id)
|
||||
if consent_at is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="user not found")
|
||||
principal.consent_at = consent_at
|
||||
return ConsentResponse(consent_at=consent_at)
|
||||
|
||||
|
||||
@router.delete("/consent", response_model=ConsentResponse)
|
||||
async def withdraw_consent(principal: CurrentPrincipal) -> ConsentResponse:
|
||||
"""Withdraw practice-session consent until the learner accepts again."""
|
||||
if principal.role != Role.LEARNER:
|
||||
raise HTTPException(status.HTTP_403_FORBIDDEN, detail="learner consent only")
|
||||
changed = await withdraw_user_consent(principal.user_id)
|
||||
if not changed:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="user not found")
|
||||
principal.consent_at = None
|
||||
return ConsentResponse(consent_at=None)
|
||||
|
||||
|
||||
@router.get("/me", response_model=MeResponse)
|
||||
async def me(principal: CurrentPrincipal) -> MeResponse:
|
||||
"""Return the current authenticated user. Unauthenticated requests are 401."""
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ from pydantic import BaseModel, Field
|
|||
from sse_starlette.sse import EventSourceResponse
|
||||
|
||||
from .. import db, session_persistence, turn_runtime
|
||||
from ..auth_sessions import user_has_consent
|
||||
from ..config import settings
|
||||
from ..deps import CurrentPrincipal, Principal, Role
|
||||
from ..engine_client import EngineError, engine_client
|
||||
|
|
@ -240,6 +241,7 @@ class SessionReviewResponse(BaseModel):
|
|||
_RECALL_CACHE: dict[str, memory.RecallContext] = {}
|
||||
# 세션별 KB 증상 행동단서(회기 1회 산출·캐시). 빈 list 캐시 = 회기 내 재시도 안 함(안정성).
|
||||
_KB_CUES_CACHE: dict[str, list[str]] = {}
|
||||
_RAG_WARM_SEMAPHORE = asyncio.Semaphore(1)
|
||||
_LEARNER_VISIBLE_AI_ROLE = "counselor"
|
||||
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
|
|
@ -409,14 +411,15 @@ async def _warm_rag_caches(session_id: str, case_id: str, card) -> None:
|
|||
BGE-M3 임베더 첫 로드(~수 초)가 회기 시작/턴 응답을 막지 않도록 create_task로 띄운다.
|
||||
warm 완료 전 턴은 빈 회상/단서로 진행(graceful), 이후 턴부터 RAG 주입. 전 구간 비치명적.
|
||||
"""
|
||||
try:
|
||||
_RECALL_CACHE[session_id] = await _build_start_recall(case_id=case_id, card=card)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
_KB_CUES_CACHE[session_id] = await _retrieve_kb_behavior_cues(card)
|
||||
except Exception:
|
||||
pass
|
||||
async with _RAG_WARM_SEMAPHORE:
|
||||
try:
|
||||
_RECALL_CACHE[session_id] = await _build_start_recall(case_id=case_id, card=card)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
_KB_CUES_CACHE[session_id] = await _retrieve_kb_behavior_cues(card)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
_PHASE_KEY_BY_LABEL = {
|
||||
|
|
@ -436,6 +439,14 @@ def _ensure_learner(principal: Principal) -> None:
|
|||
raise HTTPException(status.HTTP_403_FORBIDDEN, detail="only learners can use sessions")
|
||||
|
||||
|
||||
async def _ensure_practice_consent(principal: Principal) -> None:
|
||||
if principal.consent_at is not None:
|
||||
return
|
||||
if await user_has_consent(principal.user_id):
|
||||
return
|
||||
raise HTTPException(status.HTTP_403_FORBIDDEN, detail="consent_required")
|
||||
|
||||
|
||||
async def _load_session_or_404(
|
||||
session_id: str,
|
||||
principal: Principal,
|
||||
|
|
@ -1149,6 +1160,7 @@ async def start_session(
|
|||
) -> SessionStartResponse:
|
||||
"""Start a learner-owned practice session."""
|
||||
_ensure_learner(principal)
|
||||
await _ensure_practice_consent(principal)
|
||||
|
||||
try:
|
||||
catalog_persona = await get_catalog_persona(body.persona_code)
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ from fastapi.responses import JSONResponse
|
|||
from starlette.websockets import WebSocketState
|
||||
|
||||
from .. import session_persistence, turn_runtime
|
||||
from ..auth_sessions import get_session
|
||||
from ..auth_sessions import get_session, user_has_consent
|
||||
from ..config import settings
|
||||
from ..deps import Principal, Role
|
||||
from ..engine_client import EngineError, engine_client
|
||||
|
|
@ -431,6 +431,7 @@ async def _principal_from_websocket(websocket: WebSocket) -> Principal | None:
|
|||
cohort_ids=session.cohort_ids,
|
||||
email=session.email,
|
||||
display_name=session.display_name,
|
||||
consent_at=session.consent_at,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -457,6 +458,8 @@ async def _bind_session(
|
|||
persona_code = qp.get("persona_code")
|
||||
if not persona_code:
|
||||
return None, None, "session_id or persona_code query required", {}
|
||||
if principal.consent_at is None and not await user_has_consent(principal.user_id):
|
||||
return None, None, "consent_required", {}
|
||||
try:
|
||||
catalog_persona = await get_catalog_persona(persona_code)
|
||||
except Exception:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue