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

- 누적 작업트리 커밋: 회기 평가 복구·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

@ -27,6 +27,7 @@ from .. import session_persistence
from ..deps import Principal, Role, require_role
from ..engine_client import EngineError, engine_client
from ..runtime_policy import runtime_fallback_allowed
from ..session_evaluation_input import enriched_masked_turns
from ..session_read_model import StageLabel, stage_label_or_none
from ..services import evaluator
from ..services.evaluator import SessionEvaluation, TurnEvaluation
@ -122,13 +123,7 @@ async def reevaluate_session(
엔진 장애는 503 으로 변환(평가는 비치명적이지만 트리거는 사용자 명시 요청이라 에러 노출).
"""
sess = await _load_session_or_404(session_id, principal)
masked = sess.masked_turns()
# 발화 seq 보강(deep 프롬프트 가독성 — store 가 seq 미포함이라 인덱스로 부여)
enriched: list[dict[str, Any]] = []
for i, t in enumerate(masked):
item = dict(t)
item.setdefault("seq", i)
enriched.append(item)
enriched = enriched_masked_turns(sess.masked_turns())
# 누적 기법 코드 — DB 미가용이라 fast 결과가 없으면 빈 분포(deep LLM 정성 평가는 그대로 유효).
technique_codes: list[str] = []
@ -233,8 +228,25 @@ async def reevaluate_turn(
engine=engine_client,
audit_hook=session_persistence.record_llm_call_audit,
)
if result.error and result.error.startswith("engine_error"):
raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, detail=result.error)
result_payload = result.to_hook_dict()
turn_id = getattr(learner, "turn_id", None)
if not turn_id:
raise HTTPException(
status.HTTP_503_SERVICE_UNAVAILABLE,
detail="turn evaluation retry result was generated but the target turn has no durable id",
)
saved = await session_persistence.replace_turn_evaluation(
turn_id=turn_id,
evaluation=result_payload,
)
if not saved:
raise HTTPException(
status.HTTP_503_SERVICE_UNAVAILABLE,
detail="turn evaluation retry result was generated but could not be saved",
)
learner.evaluation = result_payload
if result.error:
raise HTTPException(_session_evaluation_error_status(result.error), detail=result.error)
return result

View file

@ -26,6 +26,7 @@ from ..deps import CurrentPrincipal, Principal, Role
from ..engine_client import EngineError, engine_client
from ..persona_repository import get_catalog_persona
from ..runtime_policy import require_runtime_fallback_allowed
from ..session_evaluation_input import enriched_masked_turns
from ..services import (
evaluator,
guardrail,
@ -42,6 +43,7 @@ from ..session_read_model import (
LearnerSessionsResponse,
LearnerSessionSummary,
LEARNER_VISIBLE_AI_ROLE,
MISSING_SESSION_EVALUATION_GRACE_SECONDS,
ReviewCaseWorksheet,
ReviewCaseWorksheetSaveRequest,
ReviewWorksheetItem,
@ -70,6 +72,8 @@ from ..store import InProcSession, TurnRecord, store
router = APIRouter(prefix="/sessions", tags=["sessions"])
logger = logging.getLogger(__name__)
_SESSION_EVALUATION_IN_FLIGHT: set[str] = set()
_SESSION_EVALUATION_RECOVERY_TASK: asyncio.Task[int] | None = None
TheoryMode = Literal["humanistic", "cbt", "integrative"]
EndStateValue = str | int | float | bool | None | dict[str, float]
@ -102,7 +106,14 @@ class LiveCoachRequest(BaseModel):
class LiveCoachHistoryResponse(BaseModel):
source: Literal["database", "runtime"] = "runtime"
quota: live_coach.LiveCoachQuota = Field(
default_factory=lambda: live_coach.LiveCoachQuota(
remaining=session_persistence.LIVE_COACH_INITIAL_CREDITS,
max=session_persistence.LIVE_COACH_MAX_CREDITS,
)
)
events: list[live_coach.LiveCoachEvent] = Field(default_factory=list)
credit_events: list[live_coach.LiveCoachCreditEvent] = Field(default_factory=list)
class CrisisResourceResponse(BaseModel):
@ -220,12 +231,20 @@ async def _retrieve_live_coach_grounding(
body = chunk.body or chunk.behavior_cue or chunk.context_prefix or ""
if not body:
continue
meta = chunk.meta if isinstance(chunk.meta, dict) else {}
title = str(meta.get("source_title") or meta.get("title") or chunk.source_id or "Vignette KB").strip()
source_type = str(meta.get("source_type") or "").strip()
source_version = str(meta.get("source_version") or meta.get("version") or "").strip()
citation = str(meta.get("citation") or "").strip()
out.append(
live_coach.LiveCoachGrounding(
source_id=chunk.source_id or f"kb:{chunk.chunk_id}",
title=chunk.source_id or "Vignette KB",
title=title or "Vignette KB",
locator=chunk.heading_path,
kb_kind=chunk.kb_kind,
source_type=source_type or None,
version=source_version or None,
citation=citation or None,
summary=body[:500],
)
)
@ -643,8 +662,13 @@ async def _evaluate_stream_turn(ctx: orchestrator.TurnContext, final_reply: str)
audit_hook=session_persistence.record_llm_call_audit,
)
return await hook(ctx, final_reply)
except Exception:
return None
except Exception as exc:
logger.warning(
"turn fast-loop evaluation failed: session_id=%s",
ctx.session_id,
exc_info=True,
)
return orchestrator.turn_evaluation_error_payload(ctx, exc)
def _stream_result_from_done(
@ -678,11 +702,7 @@ async def _generate_and_save_session_evaluation(sess: InProcSession) -> None:
return
timeout_seconds = _session_evaluation_timeout_seconds()
enriched: list[dict[str, object]] = []
for index, turn in enumerate(sess.masked_turns(), start=1):
item: dict[str, object] = dict(turn)
item["seq"] = index
enriched.append(item)
enriched = enriched_masked_turns(sess.masked_turns())
try:
result = await asyncio.wait_for(
@ -753,6 +773,7 @@ async def _generate_and_save_session_evaluation(sess: InProcSession) -> None:
def _observe_session_evaluation_task(task: asyncio.Task[None], session_id: str) -> None:
_SESSION_EVALUATION_IN_FLIGHT.discard(session_id)
try:
task.result()
except asyncio.CancelledError:
@ -764,6 +785,13 @@ def _observe_session_evaluation_task(task: asyncio.Task[None], session_id: str)
def _schedule_session_evaluation(sess: InProcSession) -> asyncio.Task[None] | None:
if not sess.turns:
return None
if sess.session_id in _SESSION_EVALUATION_IN_FLIGHT:
logger.info(
"session evaluation already scheduled: session_id=%s",
sess.session_id,
)
return None
_SESSION_EVALUATION_IN_FLIGHT.add(sess.session_id)
task = asyncio.create_task(
_generate_and_save_session_evaluation(sess),
name=f"session-evaluation:{sess.session_id}",
@ -774,6 +802,66 @@ def _schedule_session_evaluation(sess: InProcSession) -> asyncio.Task[None] | No
return task
async def recover_missing_session_evaluations(*, limit: int | None = None) -> int:
recovery_limit = settings.session_evaluation_recovery_limit if limit is None else limit
if recovery_limit <= 0:
return 0
stale_after_seconds = (
_session_evaluation_timeout_seconds()
+ MISSING_SESSION_EVALUATION_GRACE_SECONDS
)
candidates, durable = await session_persistence.list_sessions_missing_session_evaluation(
older_than_seconds=stale_after_seconds,
limit=recovery_limit,
)
if not durable:
logger.warning("session evaluation recovery skipped: durable store unavailable")
return 0
scheduled = 0
for sess in candidates:
if _schedule_session_evaluation(sess) is not None:
scheduled += 1
if scheduled:
logger.info("session evaluation recovery scheduled %d session(s)", scheduled)
return scheduled
def _observe_session_evaluation_recovery_task(task: asyncio.Task[int]) -> None:
global _SESSION_EVALUATION_RECOVERY_TASK
if _SESSION_EVALUATION_RECOVERY_TASK is task:
_SESSION_EVALUATION_RECOVERY_TASK = None
try:
task.result()
except asyncio.CancelledError:
logger.warning("session evaluation recovery task cancelled")
except Exception:
logger.exception("session evaluation recovery task crashed")
def schedule_missing_session_evaluation_recovery() -> asyncio.Task[int] | None:
global _SESSION_EVALUATION_RECOVERY_TASK
if settings.session_evaluation_recovery_limit <= 0:
return None
if (
_SESSION_EVALUATION_RECOVERY_TASK is not None
and not _SESSION_EVALUATION_RECOVERY_TASK.done()
):
return _SESSION_EVALUATION_RECOVERY_TASK
task = asyncio.create_task(
recover_missing_session_evaluations(),
name="session-evaluation-recovery",
)
_SESSION_EVALUATION_RECOVERY_TASK = task
task.add_done_callback(_observe_session_evaluation_recovery_task)
return task
def cancel_missing_session_evaluation_recovery() -> None:
task = _SESSION_EVALUATION_RECOVERY_TASK
if task is not None and not task.done():
task.cancel()
def _session_evaluation_timeout_seconds() -> float:
configured = float(settings.session_evaluation_timeout or settings.engine_timeout)
return max(configured, 1.0)
@ -792,12 +880,12 @@ async def _load_learner_sessions(
include_turn_evaluation: bool = False,
) -> tuple[list[InProcSession], bool]:
if include_turn_evaluation:
sessions, durable = await session_persistence.list_sessions(
sessions, durable = await session_persistence.list_recent_sessions(
principal,
include_turn_evaluation=True,
)
else:
sessions, durable = await session_persistence.list_sessions(principal)
sessions, durable = await session_persistence.list_recent_sessions(principal)
if not durable:
require_runtime_fallback_allowed("session list")
sessions = [
@ -1254,9 +1342,16 @@ async def list_live_coach_history(
principal = _ensure_learner(principal)
await _load_session_or_404(session_id, principal)
events, durable = await session_persistence.list_live_coach_events(session_id, principal)
quota, quota_durable = await session_persistence.get_live_coach_quota(session_id, principal)
credit_events, credit_durable = await session_persistence.list_live_coach_credit_events(
session_id,
principal,
)
return LiveCoachHistoryResponse(
source="database" if durable else "runtime",
source="database" if durable and quota_durable and credit_durable else "runtime",
quota=live_coach.LiveCoachQuota(**quota),
events=[live_coach.LiveCoachEvent(**event) for event in events],
credit_events=[live_coach.LiveCoachCreditEvent(**event) for event in credit_events],
)
@ -1269,6 +1364,12 @@ async def live_coach_turn(
"""방금 완료된 턴에 대한 비차단 라이브 코칭을 반환한다."""
principal = _ensure_learner(principal)
sess = await _load_session_or_404(session_id, principal)
quota, _ = await session_persistence.get_live_coach_quota(session_id, principal)
if int(quota.get("remaining", 0)) <= 0:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="live coach credit exhausted",
)
turn_seq = body.turn_seq or max(1, int(getattr(sess.state, "turn_seq", 1) or 1))
stage = _stage_label(sess.state.stage)
grounding = await _retrieve_live_coach_grounding(
@ -1296,16 +1397,35 @@ async def live_coach_turn(
grounding=grounding,
audit_hook=session_persistence.record_llm_call_audit,
)
await session_persistence.save_live_coach_event(
session_id=sess.session_id,
learner_id=sess.learner_id,
turn_seq=turn_seq,
stage=stage,
learner_text=body.learner_text,
client_reply=body.client_reply,
suggestion=suggestion,
try:
_, coach_event_durable = await session_persistence.save_live_coach_event(
session_id=sess.session_id,
learner_id=sess.learner_id,
turn_seq=turn_seq,
stage=stage,
learner_text=body.learner_text,
client_reply=body.client_reply,
suggestion=suggestion,
)
except session_persistence.LiveCoachCreditExhausted as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="live coach credit exhausted",
) from exc
quota_after, quota_durable = await session_persistence.get_live_coach_quota(session_id, principal)
credit_events, credit_durable = await session_persistence.list_live_coach_credit_events(session_id, principal)
turn_credit_events = [
live_coach.LiveCoachCreditEvent(**event)
for event in credit_events
if int(event.get("turn_seq") or 0) == int(turn_seq)
]
return suggestion.model_copy(
update={
"persistence_source": "database" if coach_event_durable and quota_durable and credit_durable else "runtime",
"quota": live_coach.LiveCoachQuota(**quota_after),
"credit_events": turn_credit_events[-2:],
}
)
return suggestion
@router.post("/{session_id}/stream")
@ -1398,6 +1518,7 @@ async def end_session(
"""End a learner-owned session and prepare carry-over state."""
principal = _ensure_learner(principal)
sess = await _load_session_or_404(session_id, principal, allow_ended=True)
was_ended = bool(sess.ended)
recall = _RECALL_CACHE.get(session_id) or memory.RecallContext()
carry = memory.make_carry_over(
@ -1413,7 +1534,8 @@ async def end_session(
await _end_persisted_session(sess, carry)
_RECALL_CACHE.pop(session_id, None)
_KB_CUES_CACHE.pop(session_id, None)
_schedule_session_evaluation(sess)
if not was_ended:
_schedule_session_evaluation(sess)
return SessionEndResponse(
session_id=session_id,

View file

@ -2,6 +2,7 @@
from __future__ import annotations
from datetime import datetime
from typing import Annotated, Literal
from fastapi import APIRouter, Depends, HTTPException, status
@ -10,7 +11,12 @@ from pydantic import BaseModel, Field
from .. import session_persistence
from ..deps import Principal, Role, require_role
from ..runtime_policy import require_runtime_fallback_allowed
from ..session_read_model import StageLabel, stage_label
from ..session_read_model import (
StageLabel,
learner_visible_turns,
missing_session_evaluation_record,
stage_label,
)
from ..services import session_metrics
from ..stage_contract import STAGE_LABEL_VALUES
from ..store import InProcSession, store
@ -37,6 +43,10 @@ class TeacherSessionSummary(BaseModel):
review_status: Literal["pending", "viewed", "closed"] = "pending"
review_note: str | None = None
reviewed_at: str | None = None
evaluation_status: Literal["pending", "ready", "error"] = "pending"
review_ready: bool = False
supervisor_state: Literal["기록 대기", "평가 대기", "평가 완료", "평가 실패"] = "기록 대기"
evaluation_error: str | None = None
class TeacherSessionReviewStatusRequest(BaseModel):
@ -137,9 +147,14 @@ class TeacherDashboardResponse(BaseModel):
message: str
def _learner_label(learner_id: str) -> str:
def _learner_label_from_id(learner_id: str) -> str:
suffix = learner_id[-6:] if len(learner_id) > 6 else learner_id
return f"학습자 {suffix}"
return f"학습자 {suffix}" if suffix else "학습자"
def _session_learner_label(sess: InProcSession) -> str:
label = str(getattr(sess, "learner_label", "") or "").strip()
return label or _learner_label_from_id(sess.learner_id)
def _growth_point(point: session_metrics.SessionGrowthPoint) -> TeacherGrowthPoint:
@ -163,9 +178,15 @@ def _build_learner_growth(
limit: int | None = 12,
point_limit: int | None = 6,
) -> list[TeacherLearnerGrowth]:
learner_labels = {
sess.learner_id: label
for sess in sessions
if (label := str(getattr(sess, "learner_label", "") or "").strip())
}
metrics = session_metrics.build_learner_growth(
sessions,
learner_label=_learner_label,
learner_label=lambda learner_id: learner_labels.get(learner_id)
or _learner_label_from_id(learner_id),
limit=limit,
point_limit=point_limit,
)
@ -225,22 +246,68 @@ def _worksheet_review_status_value(
return "pending"
def _evaluation_status_value(record: dict[str, object] | None) -> Literal["pending", "ready", "error"]:
value = str((record or {}).get("status") or "pending")
if value in {"ready", "error"}:
return value # type: ignore[return-value]
return "pending"
def _supervisor_state(
sess: InProcSession,
evaluation_record: dict[str, object] | None,
*,
has_visible_turns: bool | None = None,
) -> Literal["기록 대기", "평가 대기", "평가 완료", "평가 실패"]:
status = _evaluation_status_value(evaluation_record)
if status == "ready":
return "평가 완료"
if status == "error":
return "평가 실패"
if has_visible_turns is None:
has_visible_turns = bool(learner_visible_turns(sess))
if has_visible_turns:
return "평가 대기"
return "기록 대기"
def _dashboard_evaluation_record(
sess: InProcSession,
evaluation_record: dict[str, object] | None,
) -> dict[str, object] | None:
visible_turns = learner_visible_turns(sess)
hidden_turns = len(visible_turns) != len(sess.turns)
if evaluation_record is not None:
return None if hidden_turns else evaluation_record
if hidden_turns:
return None
return missing_session_evaluation_record(
sess,
has_visible_turns=bool(visible_turns),
now_ts=datetime.now().timestamp(),
)
def _summary(
sess: InProcSession,
review_status: dict[str, object] | None = None,
evaluation_record: dict[str, object] | None = None,
) -> TeacherSessionSummary:
learner_turns = sum(1 for turn in sess.turns if turn.speaker == "counselor")
client_turns = sum(1 for turn in sess.turns if turn.speaker == "client")
visible_turns = learner_visible_turns(sess)
summary_evaluation_record = _dashboard_evaluation_record(sess, evaluation_record)
learner_turns = sum(1 for turn in visible_turns if turn.speaker == "counselor")
client_turns = sum(1 for turn in visible_turns if turn.speaker == "client")
evaluation_status = _evaluation_status_value(summary_evaluation_record)
return TeacherSessionSummary(
session_id=sess.session_id,
learner_id=sess.learner_id,
learner_label=_learner_label(sess.learner_id),
learner_label=_session_learner_label(sess),
persona_code=sess.persona_code,
persona_name=sess.persona.display_name,
session_no=sess.session_no,
status="ended" if sess.ended else "active",
stage=stage_label(sess.state.stage),
turn_count=len(sess.turns),
turn_count=len(visible_turns),
learner_turn_count=learner_turns,
client_turn_count=client_turns,
started_at=session_metrics.iso_datetime(sess.created_at) or "",
@ -248,34 +315,53 @@ def _summary(
review_status=_review_status_value(review_status),
review_note=str(review_status.get("note") or "") if review_status else None,
reviewed_at=str(review_status.get("reviewed_at") or "") if review_status else None,
evaluation_status=evaluation_status,
review_ready=evaluation_status == "ready",
supervisor_state=_supervisor_state(
sess,
summary_evaluation_record,
has_visible_turns=bool(visible_turns),
),
evaluation_error=(
str(summary_evaluation_record.get("error") or "") if summary_evaluation_record else None
),
)
def _needs_teacher_review(item: TeacherSessionSummary) -> bool:
if item.status != "ended" or item.review_status == "closed":
return False
return item.turn_count > 0 or item.evaluation_status in {"ready", "error"}
@router.get("/dashboard", response_model=TeacherDashboardResponse)
async def teacher_dashboard(principal: TeacherPrincipal) -> TeacherDashboardResponse:
"""Return teacher-visible dashboard data from real sessions only."""
sessions, durable = await session_persistence.list_sessions(
sessions, durable = await session_persistence.list_all_sessions(
principal,
include_turn_evaluation=True,
)
if not durable:
require_runtime_fallback_allowed("teacher dashboard")
sessions = sorted(store.list(), key=lambda sess: sess.created_at, reverse=True)
ended_session_ids = [sess.session_id for sess in sessions if sess.ended]
review_statuses, _ = await session_persistence.list_session_review_statuses(
[sess.session_id for sess in sessions if sess.ended],
ended_session_ids,
principal,
)
evaluation_records, evaluations_durable = await session_persistence.list_session_evaluations(
ended_session_ids,
principal,
)
if not evaluations_durable:
require_runtime_fallback_allowed("session evaluation list")
summaries = [
_summary(sess, review_statuses.get(sess.session_id))
_summary(sess, review_statuses.get(sess.session_id), evaluation_records.get(sess.session_id))
for sess in sessions
]
pending_reviews = [
item
for item in summaries
if item.status == "ended" and item.review_status != "closed"
]
pending_reviews = [item for item in summaries if _needs_teacher_review(item)]
learners = {sess.learner_id for sess in sessions}
learner_growth = _build_learner_growth(sessions)
learner_growth = _build_learner_growth(sessions, limit=None)
safety_alerts: list[TeacherSafetyAlert] = []
if durable:
raw_alerts, alerts_durable = await session_persistence.list_safety_alerts(principal)
@ -334,7 +420,7 @@ async def learner_analysis(
principal: TeacherPrincipal,
) -> TeacherLearnerAnalysisResponse:
"""Return one learner's full teacher-visible session timeline and trend."""
sessions, durable = await session_persistence.list_sessions(
sessions, durable = await session_persistence.list_all_sessions(
principal,
include_turn_evaluation=True,
)
@ -350,20 +436,23 @@ async def learner_analysis(
learner_sessions,
key=lambda sess: (sess.created_at, sess.session_no, sess.session_id),
)
ended_session_ids = [sess.session_id for sess in ordered if sess.ended]
review_statuses, _ = await session_persistence.list_session_review_statuses(
[sess.session_id for sess in ordered if sess.ended],
ended_session_ids,
principal,
)
evaluation_records, evaluations_durable = await session_persistence.list_session_evaluations(
ended_session_ids,
principal,
)
if not evaluations_durable:
require_runtime_fallback_allowed("session evaluation list")
summaries = [
_summary(sess, review_statuses.get(sess.session_id))
_summary(sess, review_statuses.get(sess.session_id), evaluation_records.get(sess.session_id))
for sess in ordered
]
growth = _build_learner_growth(ordered, limit=None, point_limit=None)[0]
pending_reviews = sum(
1
for item in summaries
if item.status == "ended" and item.review_status != "closed"
)
pending_reviews = sum(1 for item in summaries if _needs_teacher_review(item))
closed_reviews = sum(1 for item in summaries if item.review_status == "closed")
return TeacherLearnerAnalysisResponse(
@ -404,6 +493,18 @@ async def update_session_review_status(
status_code=status.HTTP_409_CONFLICT,
detail="active sessions cannot be closed as reviewed",
)
if sess.ended and request.status == "closed" and learner_visible_turns(sess):
evaluation_record, evaluation_durable = await session_persistence.load_session_evaluation(
session_id,
principal,
)
if not evaluation_durable:
require_runtime_fallback_allowed("session evaluation")
if _evaluation_status_value(evaluation_record) != "ready":
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="session evaluation must be ready before closing teacher review",
)
saved, _ = await session_persistence.save_session_review_status(
session_id=session_id,
reviewer_id=principal.user_id,

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: