동의 게이트와 런타임 안정화
This commit is contained in:
parent
0eb7d925ed
commit
0ec266a761
34 changed files with 1186 additions and 158 deletions
|
|
@ -28,6 +28,7 @@ class SessionUser:
|
|||
display_name: str
|
||||
role: str
|
||||
cohort_ids: list[str]
|
||||
consent_at: float | None
|
||||
expires_at: float
|
||||
|
||||
|
||||
|
|
@ -39,6 +40,7 @@ class ManagedUser:
|
|||
role: str
|
||||
cohort_ids: list[str]
|
||||
affiliation: str
|
||||
consent_at: float | None
|
||||
created_at: float
|
||||
last_seen_at: float
|
||||
|
||||
|
|
@ -95,6 +97,17 @@ def _ts(value: datetime | None) -> float:
|
|||
return (value or datetime.now(timezone.utc)).timestamp()
|
||||
|
||||
|
||||
def _optional_ts(value: datetime | None) -> float | None:
|
||||
return value.timestamp() if value is not None else None
|
||||
|
||||
|
||||
def _row_value(row, key: str, default=None):
|
||||
try:
|
||||
return row[key]
|
||||
except (IndexError, KeyError, TypeError):
|
||||
return default
|
||||
|
||||
|
||||
def _cohort_ids(cohort: str | None) -> list[str]:
|
||||
return [cohort] if cohort else []
|
||||
|
||||
|
|
@ -111,9 +124,9 @@ async def _runtime_tables_ready(conn) -> bool:
|
|||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = 'app'
|
||||
AND table_name = 'app_user'
|
||||
AND column_name IN ('affiliation', 'last_seen_at', 'updated_at')
|
||||
AND column_name IN ('affiliation', 'consent_at', 'last_seen_at', 'updated_at')
|
||||
GROUP BY table_schema, table_name
|
||||
HAVING count(*) = 3
|
||||
HAVING count(*) = 4
|
||||
) AS has_user_columns,
|
||||
to_regclass('app.auth_session') IS NOT NULL AS has_auth_session,
|
||||
to_regclass('app.user_preferences') IS NOT NULL AS has_preferences,
|
||||
|
|
@ -214,6 +227,7 @@ async def ensure_runtime_tables() -> None:
|
|||
"""
|
||||
ALTER TABLE app.app_user
|
||||
ADD COLUMN IF NOT EXISTS affiliation TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS consent_at TIMESTAMPTZ,
|
||||
ADD COLUMN IF NOT EXISTS last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
"""
|
||||
|
|
@ -388,8 +402,9 @@ def _managed_user_from_row(row) -> ManagedUser:
|
|||
email=row["email"],
|
||||
display_name=row["display_name"] or row["email"],
|
||||
role=_app_role(row["role"]),
|
||||
cohort_ids=_cohort_ids(row.get("cohort") if hasattr(row, "get") else row["cohort"]),
|
||||
cohort_ids=_cohort_ids(_row_value(row, "cohort")),
|
||||
affiliation=row["affiliation"] or DEFAULT_AFFILIATION,
|
||||
consent_at=_optional_ts(_row_value(row, "consent_at")),
|
||||
created_at=_ts(row["created_at"]),
|
||||
last_seen_at=_ts(row["last_seen_at"]),
|
||||
)
|
||||
|
|
@ -403,6 +418,7 @@ def _memory_upsert_managed_user(
|
|||
cohort_ids: list[str] | None = None,
|
||||
user_id: str | None = None,
|
||||
affiliation: str | None = None,
|
||||
consent_at: float | None = None,
|
||||
reactivate: bool = False,
|
||||
) -> ManagedUser:
|
||||
now = time.time()
|
||||
|
|
@ -424,6 +440,7 @@ def _memory_upsert_managed_user(
|
|||
if affiliation
|
||||
else (current.affiliation if current else DEFAULT_AFFILIATION)
|
||||
),
|
||||
consent_at=consent_at if consent_at is not None else (current.consent_at if current else None),
|
||||
created_at=current.created_at if current else now,
|
||||
last_seen_at=now,
|
||||
)
|
||||
|
|
@ -464,7 +481,7 @@ async def upsert_managed_user(
|
|||
last_seen_at = now(),
|
||||
updated_at = now()
|
||||
WHERE app.app_user.is_active OR $7
|
||||
RETURNING user_id, email, display_name, role, cohort, affiliation, created_at, last_seen_at
|
||||
RETURNING user_id, email, display_name, role, cohort, affiliation, consent_at, created_at, last_seen_at
|
||||
""",
|
||||
normalized_external_id,
|
||||
normalized_email,
|
||||
|
|
@ -485,6 +502,7 @@ async def upsert_managed_user(
|
|||
cohort_ids=user.cohort_ids,
|
||||
user_id=user.user_id,
|
||||
affiliation=user.affiliation,
|
||||
consent_at=user.consent_at,
|
||||
reactivate=True,
|
||||
)
|
||||
return user
|
||||
|
|
@ -509,7 +527,7 @@ async def get_managed_user(user_id: str) -> ManagedUser | None:
|
|||
async with pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT user_id, email, display_name, role, cohort, affiliation, created_at, last_seen_at
|
||||
SELECT user_id, email, display_name, role, cohort, affiliation, consent_at, created_at, last_seen_at
|
||||
FROM app.app_user
|
||||
WHERE user_id = $1::uuid AND is_active
|
||||
""",
|
||||
|
|
@ -530,7 +548,7 @@ async def list_managed_users() -> tuple[list[ManagedUser], bool]:
|
|||
async with pool.acquire() as conn:
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT user_id, email, display_name, role, cohort, affiliation, created_at, last_seen_at
|
||||
SELECT user_id, email, display_name, role, cohort, affiliation, consent_at, created_at, last_seen_at
|
||||
FROM app.app_user
|
||||
WHERE is_active
|
||||
ORDER BY last_seen_at DESC
|
||||
|
|
@ -563,7 +581,7 @@ async def update_managed_user(
|
|||
updated_at = now(),
|
||||
last_seen_at = now()
|
||||
WHERE user_id = $1::uuid AND is_active
|
||||
RETURNING user_id, email, display_name, role, cohort, affiliation, created_at, last_seen_at
|
||||
RETURNING user_id, email, display_name, role, cohort, affiliation, consent_at, created_at, last_seen_at
|
||||
""",
|
||||
user_id,
|
||||
display_name.strip() if display_name is not None else None,
|
||||
|
|
@ -581,12 +599,14 @@ async def update_managed_user(
|
|||
cohort_ids=next_user.cohort_ids,
|
||||
user_id=next_user.user_id,
|
||||
affiliation=next_user.affiliation,
|
||||
consent_at=next_user.consent_at,
|
||||
)
|
||||
for session in _sessions.values():
|
||||
if session.user_id == user_id:
|
||||
session.display_name = next_user.display_name
|
||||
session.role = next_user.role
|
||||
session.cohort_ids = list(next_user.cohort_ids)
|
||||
session.consent_at = next_user.consent_at
|
||||
return next_user
|
||||
except Exception:
|
||||
require_runtime_fallback_allowed("managed user update")
|
||||
|
|
@ -604,6 +624,7 @@ async def update_managed_user(
|
|||
role=role if role is not None else current.role,
|
||||
cohort_ids=list(cohort_ids) if cohort_ids is not None else current.cohort_ids,
|
||||
affiliation=affiliation.strip() if affiliation is not None else current.affiliation,
|
||||
consent_at=current.consent_at,
|
||||
created_at=current.created_at,
|
||||
last_seen_at=time.time(),
|
||||
)
|
||||
|
|
@ -614,9 +635,118 @@ async def update_managed_user(
|
|||
session.display_name = next_user.display_name
|
||||
session.role = next_user.role
|
||||
session.cohort_ids = list(next_user.cohort_ids)
|
||||
session.consent_at = next_user.consent_at
|
||||
return next_user
|
||||
|
||||
|
||||
async def record_user_consent(user_id: str) -> float | None:
|
||||
"""Mark a learner consent receipt timestamp for the current user."""
|
||||
try:
|
||||
pool = get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
UPDATE app.app_user SET
|
||||
consent_at = now(),
|
||||
updated_at = now(),
|
||||
last_seen_at = now()
|
||||
WHERE user_id = $1::uuid AND is_active
|
||||
RETURNING consent_at
|
||||
""",
|
||||
user_id,
|
||||
)
|
||||
if row is not None:
|
||||
consent_at = _optional_ts(row["consent_at"])
|
||||
memory_user = _users.get(user_id)
|
||||
if memory_user is not None:
|
||||
_users[user_id] = ManagedUser(
|
||||
user_id=memory_user.user_id,
|
||||
email=memory_user.email,
|
||||
display_name=memory_user.display_name,
|
||||
role=memory_user.role,
|
||||
cohort_ids=list(memory_user.cohort_ids),
|
||||
affiliation=memory_user.affiliation,
|
||||
consent_at=consent_at,
|
||||
created_at=memory_user.created_at,
|
||||
last_seen_at=time.time(),
|
||||
)
|
||||
for session in _sessions.values():
|
||||
if session.user_id == user_id:
|
||||
session.consent_at = consent_at
|
||||
return consent_at
|
||||
except Exception:
|
||||
require_runtime_fallback_allowed("consent update")
|
||||
|
||||
if not runtime_fallback_allowed():
|
||||
return None
|
||||
current = _users.get(user_id)
|
||||
if current is None:
|
||||
return None
|
||||
consent_at = time.time()
|
||||
_users[user_id] = ManagedUser(
|
||||
user_id=current.user_id,
|
||||
email=current.email,
|
||||
display_name=current.display_name,
|
||||
role=current.role,
|
||||
cohort_ids=list(current.cohort_ids),
|
||||
affiliation=current.affiliation,
|
||||
consent_at=consent_at,
|
||||
created_at=current.created_at,
|
||||
last_seen_at=consent_at,
|
||||
)
|
||||
for session in _sessions.values():
|
||||
if session.user_id == user_id:
|
||||
session.consent_at = consent_at
|
||||
return consent_at
|
||||
|
||||
|
||||
async def withdraw_user_consent(user_id: str) -> bool:
|
||||
"""Clear consent and revoke practice eligibility until the user consents again."""
|
||||
changed = False
|
||||
try:
|
||||
pool = get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
result = await conn.execute(
|
||||
"""
|
||||
UPDATE app.app_user SET
|
||||
consent_at = NULL,
|
||||
updated_at = now(),
|
||||
last_seen_at = now()
|
||||
WHERE user_id = $1::uuid AND is_active
|
||||
""",
|
||||
user_id,
|
||||
)
|
||||
changed = result.endswith(" 1")
|
||||
except Exception:
|
||||
require_runtime_fallback_allowed("consent withdrawal")
|
||||
|
||||
if not runtime_fallback_allowed():
|
||||
return changed
|
||||
current = _users.get(user_id)
|
||||
if current is not None:
|
||||
_users[user_id] = ManagedUser(
|
||||
user_id=current.user_id,
|
||||
email=current.email,
|
||||
display_name=current.display_name,
|
||||
role=current.role,
|
||||
cohort_ids=list(current.cohort_ids),
|
||||
affiliation=current.affiliation,
|
||||
consent_at=None,
|
||||
created_at=current.created_at,
|
||||
last_seen_at=time.time(),
|
||||
)
|
||||
changed = True
|
||||
for session in _sessions.values():
|
||||
if session.user_id == user_id:
|
||||
session.consent_at = None
|
||||
return changed
|
||||
|
||||
|
||||
async def user_has_consent(user_id: str) -> bool:
|
||||
managed = await get_managed_user(user_id)
|
||||
return bool(managed and managed.consent_at is not None)
|
||||
|
||||
|
||||
async def deactivate_managed_user(user_id: str) -> bool:
|
||||
"""Deactivate a managed user and revoke their active browser sessions."""
|
||||
changed = False
|
||||
|
|
@ -718,6 +848,7 @@ async def create_session(
|
|||
display_name=managed.display_name,
|
||||
role=managed.role,
|
||||
cohort_ids=list(managed.cohort_ids),
|
||||
consent_at=managed.consent_at,
|
||||
expires_at=expires_at,
|
||||
)
|
||||
sid_hash = _sid_hash(raw_sid)
|
||||
|
|
@ -767,7 +898,8 @@ async def get_session(raw_sid: str | None) -> SessionUser | None:
|
|||
u.email,
|
||||
COALESCE(u.display_name, s.display_name, u.email) AS display_name,
|
||||
u.role,
|
||||
u.cohort
|
||||
u.cohort,
|
||||
u.consent_at
|
||||
FROM app.auth_session s
|
||||
JOIN app.app_user u ON u.user_id = s.user_id
|
||||
WHERE s.sid_hash = $1
|
||||
|
|
@ -792,6 +924,7 @@ async def get_session(raw_sid: str | None) -> SessionUser | None:
|
|||
display_name=row["display_name"],
|
||||
role=_app_role(row["role"]),
|
||||
cohort_ids=_cohort_ids(row["cohort"]),
|
||||
consent_at=_optional_ts(row["consent_at"]),
|
||||
expires_at=_ts(row["expires_at"]),
|
||||
)
|
||||
except Exception:
|
||||
|
|
|
|||
|
|
@ -35,12 +35,14 @@ class Principal:
|
|||
cohort_ids: Optional[list[str]] = None,
|
||||
email: str = "",
|
||||
display_name: str = "",
|
||||
consent_at: float | None = None,
|
||||
) -> None:
|
||||
self.user_id = user_id
|
||||
self.role = role
|
||||
self.cohort_ids = cohort_ids or []
|
||||
self.email = email
|
||||
self.display_name = display_name
|
||||
self.consent_at = consent_at
|
||||
|
||||
|
||||
def get_settings_dep() -> Settings:
|
||||
|
|
@ -74,6 +76,7 @@ async def get_current_principal(
|
|||
cohort_ids=session.cohort_ids,
|
||||
email=session.email,
|
||||
display_name=session.display_name,
|
||||
consent_at=session.consent_at,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ from __future__ import annotations
|
|||
|
||||
import asyncio
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
|
|
@ -159,6 +160,7 @@ CRAG_TOP1_THRESHOLD = 0.35
|
|||
# ════════════════════════════════════════════════════════════════════════════
|
||||
_EMBEDDER: Any = None # FlagEmbedding.BGEM3FlagModel 인스턴스(지연 로딩 캐시)
|
||||
_EMBEDDER_FAILED = False # 모델 로드 실패 1회 기록(반복 시도 방지)
|
||||
_EMBEDDER_LOCK = threading.RLock()
|
||||
BGE_M3_MODEL = "BAAI/bge-m3"
|
||||
EMBED_DIM = 1024 # kb.chunk.embedding vector(1024) 와 정합 — 어기면 DB 캐스트 실패
|
||||
|
||||
|
|
@ -171,22 +173,25 @@ def _get_embedder() -> Any:
|
|||
global _EMBEDDER, _EMBEDDER_FAILED
|
||||
if _EMBEDDER is not None:
|
||||
return _EMBEDDER
|
||||
if _EMBEDDER_FAILED:
|
||||
raise NotConfigured("BGE-M3 embedder unavailable (prior load failure)")
|
||||
try:
|
||||
from FlagEmbedding import BGEM3FlagModel # 무거운 의존성 — 지연 import
|
||||
except Exception as e: # ImportError 포함(미설치 환경)
|
||||
_EMBEDDER_FAILED = True
|
||||
raise NotConfigured(
|
||||
"FlagEmbedding(BGE-M3) not installed — requirements-rag.txt 필요"
|
||||
) from e
|
||||
try:
|
||||
# use_fp16: GPU 시 절반정밀(속도). CPU 면 무시됨.
|
||||
_EMBEDDER = BGEM3FlagModel(BGE_M3_MODEL, use_fp16=True)
|
||||
except Exception as e:
|
||||
_EMBEDDER_FAILED = True
|
||||
raise NotConfigured(f"BGE-M3 model load failed: {e}") from e
|
||||
return _EMBEDDER
|
||||
with _EMBEDDER_LOCK:
|
||||
if _EMBEDDER is not None:
|
||||
return _EMBEDDER
|
||||
if _EMBEDDER_FAILED:
|
||||
raise NotConfigured("BGE-M3 embedder unavailable (prior load failure)")
|
||||
try:
|
||||
from FlagEmbedding import BGEM3FlagModel # 무거운 의존성 — 지연 import
|
||||
except Exception as e: # ImportError 포함(미설치 환경)
|
||||
_EMBEDDER_FAILED = True
|
||||
raise NotConfigured(
|
||||
"FlagEmbedding(BGE-M3) not installed — requirements-rag.txt 필요"
|
||||
) from e
|
||||
try:
|
||||
# use_fp16: GPU 시 절반정밀(속도). CPU 면 무시됨.
|
||||
_EMBEDDER = BGEM3FlagModel(BGE_M3_MODEL, use_fp16=True)
|
||||
except Exception as e:
|
||||
_EMBEDDER_FAILED = True
|
||||
raise NotConfigured(f"BGE-M3 model load failed: {e}") from e
|
||||
return _EMBEDDER
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
|
|
@ -202,13 +207,14 @@ def embed_query(text: str) -> EmbeddedQuery:
|
|||
|
||||
인덱싱 시점(오프라인 배치)에도 같은 함수로 청크 임베딩을 산출한다(동일 모델 재사용).
|
||||
"""
|
||||
model = _get_embedder()
|
||||
out = model.encode(
|
||||
[text],
|
||||
return_dense=True,
|
||||
return_sparse=True,
|
||||
return_colbert_vecs=False, # 멀티벡터는 런타임 회수에 미사용(인덱싱만)
|
||||
)
|
||||
with _EMBEDDER_LOCK:
|
||||
model = _get_embedder()
|
||||
out = model.encode(
|
||||
[text],
|
||||
return_dense=True,
|
||||
return_sparse=True,
|
||||
return_colbert_vecs=False, # 멀티벡터는 런타임 회수에 미사용(인덱싱만)
|
||||
)
|
||||
dense_vec = out["dense_vecs"][0]
|
||||
# numpy → list[float] (asyncpg pgvector 텍스트 캐스트 호환). tolist() 있으면 사용.
|
||||
dense = dense_vec.tolist() if hasattr(dense_vec, "tolist") else list(dense_vec)
|
||||
|
|
|
|||
|
|
@ -12,7 +12,9 @@ from fastapi import Response
|
|||
from starlette.requests import Request
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from . import auth_sessions
|
||||
from .config import Settings, settings
|
||||
from .deps import Principal, Role
|
||||
from .routes import auth as auth_routes
|
||||
from .saml import inflate_redirect_request
|
||||
|
||||
|
|
@ -94,10 +96,18 @@ class AuthProviderScaffoldTest(unittest.IsolatedAsyncioTestCase):
|
|||
async def asyncSetUp(self) -> None:
|
||||
auth_routes._oauth_states.clear()
|
||||
auth_routes._saml_states.clear()
|
||||
auth_sessions._sessions.clear()
|
||||
auth_sessions._users.clear()
|
||||
auth_sessions._email_index.clear()
|
||||
auth_sessions._inactive_emails.clear()
|
||||
|
||||
async def asyncTearDown(self) -> None:
|
||||
auth_routes._oauth_states.clear()
|
||||
auth_routes._saml_states.clear()
|
||||
auth_sessions._sessions.clear()
|
||||
auth_sessions._users.clear()
|
||||
auth_sessions._email_index.clear()
|
||||
auth_sessions._inactive_emails.clear()
|
||||
|
||||
async def test_auth_config_reports_google_and_saml_provider_status(self) -> None:
|
||||
with patched_settings(
|
||||
|
|
@ -134,6 +144,60 @@ class AuthProviderScaffoldTest(unittest.IsolatedAsyncioTestCase):
|
|||
|
||||
self.assertTrue(config.dev_login_enabled)
|
||||
|
||||
async def test_learner_can_accept_and_withdraw_practice_consent(self) -> None:
|
||||
with patch.object(auth_sessions, "get_pool", side_effect=RuntimeError("no db")):
|
||||
_, user = await auth_sessions.create_session(
|
||||
email="learner@hs.ac.kr",
|
||||
display_name="Learner",
|
||||
role="learner",
|
||||
external_id="dev:learner@hs.ac.kr",
|
||||
)
|
||||
|
||||
principal = Principal(
|
||||
user_id=user.user_id,
|
||||
role=Role.LEARNER,
|
||||
cohort_ids=user.cohort_ids,
|
||||
email=user.email,
|
||||
display_name=user.display_name,
|
||||
consent_at=user.consent_at,
|
||||
)
|
||||
accepted = await auth_routes.accept_consent(
|
||||
auth_routes.ConsentRequest(accepted=True),
|
||||
principal,
|
||||
)
|
||||
|
||||
self.assertIsNotNone(accepted.consent_at)
|
||||
self.assertEqual(principal.consent_at, accepted.consent_at)
|
||||
self.assertTrue(await auth_sessions.user_has_consent(user.user_id))
|
||||
|
||||
withdrawn = await auth_routes.withdraw_consent(principal)
|
||||
|
||||
self.assertIsNone(withdrawn.consent_at)
|
||||
self.assertIsNone(principal.consent_at)
|
||||
self.assertFalse(await auth_sessions.user_has_consent(user.user_id))
|
||||
|
||||
async def test_consent_rejects_non_learner_and_unaccepted_body(self) -> None:
|
||||
teacher = Principal(
|
||||
user_id="00000000-0000-0000-0000-000000000501",
|
||||
role=Role.TEACHER,
|
||||
email="teacher@hs.ac.kr",
|
||||
display_name="Teacher",
|
||||
)
|
||||
learner = Principal(
|
||||
user_id="00000000-0000-0000-0000-000000000502",
|
||||
role=Role.LEARNER,
|
||||
email="learner@hs.ac.kr",
|
||||
display_name="Learner",
|
||||
)
|
||||
|
||||
with self.assertRaises(auth_routes.HTTPException) as teacher_error:
|
||||
await auth_routes.accept_consent(auth_routes.ConsentRequest(), teacher)
|
||||
with self.assertRaises(auth_routes.HTTPException) as learner_error:
|
||||
await auth_routes.accept_consent(auth_routes.ConsentRequest(accepted=False), learner)
|
||||
|
||||
self.assertEqual(teacher_error.exception.status_code, 403)
|
||||
self.assertEqual(learner_error.exception.status_code, 400)
|
||||
|
||||
async def test_auth_config_allows_dev_login_from_configured_tailnet_forwarded_host(self) -> None:
|
||||
request = _request(
|
||||
[
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ def _principal(role: Role = Role.LEARNER) -> Principal:
|
|||
cohort_ids=["cohort-a"] if role == Role.TEACHER else [],
|
||||
email=f"{role.value}@example.test",
|
||||
display_name=role.value.title(),
|
||||
consent_at=1.0 if role == Role.LEARNER else None,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ def _principal() -> Principal:
|
|||
cohort_ids=[],
|
||||
email="turn-test@hs.ac.kr",
|
||||
display_name="Turn Test",
|
||||
consent_at=1.0,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -374,6 +375,25 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
|
|||
self.assertEqual(response.recall_summary, recall.recall_summary)
|
||||
self.assertIs(sessions._RECALL_CACHE[response.session_id], recall)
|
||||
|
||||
async def test_start_session_requires_learner_consent_before_catalog_lookup(self) -> None:
|
||||
principal = _principal()
|
||||
principal.consent_at = None
|
||||
|
||||
with patch.object(
|
||||
sessions,
|
||||
"get_catalog_persona",
|
||||
AsyncMock(side_effect=AssertionError("consent gate must run before catalog lookup")),
|
||||
) as get_persona:
|
||||
with self.assertRaises(sessions.HTTPException) as caught:
|
||||
await sessions.start_session(
|
||||
sessions.SessionStartRequest(persona_code=persona_service.P1.code),
|
||||
principal,
|
||||
)
|
||||
|
||||
self.assertEqual(caught.exception.status_code, 403)
|
||||
self.assertEqual(caught.exception.detail, "consent_required")
|
||||
get_persona.assert_not_awaited()
|
||||
|
||||
async def test_run_turn_stream_parses_gateway_done_telemetry(self) -> None:
|
||||
class FakeStreamEngine:
|
||||
engine_mode = "claude_cli"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue