세션 평가·라이브코치·교수자 분석 라운드 마감 + 문서 정리 + 코드품질 리팩터

- 누적 작업트리 커밋: 회기 평가 복구·durable 저장, 라이브 코치 이력/근거, 교수자 학생분석, 음성 비언어 메타, PII 마스킹, 운영 티켓/헬스 등
- 문서: 완료 기록 docs/archive/ 냉동 보관, docs/ 단일 인덱스(docs/README.md)+통합 TODO(docs/TODO.md)로 정리
- 리팩터(행위 보존): Stage enum SSOT(taxonomy 소유·state_machine re-export), store recent/masked_turns 중복 제거, speaker_ko_label 단일 헬퍼, _list_sessions N+1 제거(state/turns 배치 + 턴평가 하이드레이션 배치)
- 검증: 백엔드 pytest 352 passed, _list_sessions E2E chromium-single-run 2 passed
This commit is contained in:
Yun Chan 2026-07-02 02:50:36 +09:00
parent 7c41c3ce79
commit 778e8526d4
108 changed files with 6457 additions and 455 deletions

View file

@ -18,7 +18,7 @@ import hashlib
import time
from typing import Optional
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, HTTPException
from fastapi.responses import JSONResponse
from starlette.websockets import WebSocketState
@ -97,6 +97,13 @@ _PROVIDER_EVENT_TAXONOMY = {
"noise": ("background_noise", "audio_quality"),
"background_noise": ("background_noise", "audio_quality"),
}
def _is_turn_persistence_unavailable(exc: Exception) -> bool:
if not isinstance(exc, HTTPException) or exc.status_code != 503:
return False
detail = str(exc.detail or "")
return "turn append" in detail and "persistence unavailable" in detail
_PROVIDER_EVENT_TYPE_FIELDS = ("event_type", "type", "kind", "label")
@ -303,7 +310,18 @@ async def voice_ws(websocket: WebSocket) -> None:
except WebSocketDisconnect:
pass
except Exception as e:
await _safe_send_json(websocket, {"type": "error", "detail": f"voice ws error: {e}"})
if _is_turn_persistence_unavailable(e):
await _safe_send_json(
websocket,
{
"type": "error",
"code": "turn_persistence_unavailable",
"detail": "voice turn persistence unavailable; retry the utterance",
},
)
await _safe_send_json(websocket, {"type": "state", "state": "idle"})
else:
await _safe_send_json(websocket, {"type": "error", "detail": f"voice ws error: {e}"})
finally:
await _safe_close(websocket)
@ -609,6 +627,17 @@ async def _principal_from_websocket(websocket: WebSocket) -> Principal | None:
)
async def _practice_access_error(principal: Principal) -> str | None:
if (
principal.profile_completed_at is None
and not await user_onboarding_complete(principal.user_id)
):
return "onboarding_required"
if principal.consent_at is None and not await user_has_consent(principal.user_id):
return "consent_required"
return None
async def _bind_session(
websocket: WebSocket,
principal: Principal,
@ -616,6 +645,9 @@ async def _bind_session(
"""Bind an existing session or create a dev-only voice session."""
qp = websocket.query_params
explicit_preset = qp.get("preset")
access_error = await _practice_access_error(principal)
if access_error is not None:
return None, None, access_error, {}
session_id = qp.get("session_id")
if session_id:
@ -636,13 +668,6 @@ 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.profile_completed_at is None
and not await user_onboarding_complete(principal.user_id)
):
return None, None, "onboarding_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: