"""REST/WS 공용 턴 런타임 헬퍼. 세션 로드, 오너십 검증, 완료 턴 영속화, 상태 갱신은 REST 세션 라우트와 음성 WebSocket 라우트가 같은 규칙을 공유해야 한다. """ from __future__ import annotations from enum import Enum import json from typing import Optional from . import db, session_persistence from .deps import Principal from .runtime_policy import require_runtime_fallback_allowed, runtime_fallback_allowed from .services import guardrail, orchestrator, state_machine from .store import InProcSession, TurnRecord, store _STAGE_LABELS = { "RAPPORT": "라포", "EXPLORE": "탐색", "INTERVENE": "개입", "CLOSE": "정리", "rapport": "라포", "explore": "탐색", "intervene": "개입", "close": "정리", } def stage_label(stage: object) -> str: """Stage enum과 문자열 값을 같은 한글 라벨로 정규화한다.""" name = getattr(stage, "name", "") raw = str(getattr(stage, "value", stage)) return _STAGE_LABELS.get(name) or _STAGE_LABELS.get(raw) or raw class SessionAccessError(str, Enum): NOT_FOUND = "not_found" FORBIDDEN = "forbidden" ENDED = "ended" async def load_owned_session( session_id: str, principal: Principal, *, allow_ended: bool = False, include_turn_evaluation: bool = False, ) -> tuple[InProcSession | None, Optional[SessionAccessError]]: """DB 우선으로 학습자 소유 세션을 로드하고 접근 오류를 코드로 반환한다.""" sess = await session_persistence.load_session( session_id, principal, allow_ended=True, include_turn_evaluation=include_turn_evaluation, ) if sess is not None: store.put(sess) elif runtime_fallback_allowed(): sess = store.get(session_id) if sess is None: return None, SessionAccessError.NOT_FOUND if sess.learner_id != principal.user_id: return None, SessionAccessError.FORBIDDEN if sess.ended and not allow_ended: return None, SessionAccessError.ENDED return sess, None async def append_completed_turn( sess: InProcSession, turn: TurnRecord, *, context: str, ) -> None: """완료된 턴을 DB와 in-process 미러에 기록한다.""" if await session_persistence.append_turn( session_id=sess.session_id, learner_id=sess.learner_id, turn=turn, ): sess.turns.append(turn) store.put(sess) return require_runtime_fallback_allowed(context) store.append_turn(sess.session_id, turn) async def update_session_state( sess: InProcSession, state: state_machine.SessionState, *, context: str, ) -> None: """working state를 DB와 in-process 미러에 반영한다.""" if await session_persistence.update_state( session_id=sess.session_id, learner_id=sess.learner_id, state=state, ): sess.state = state store.put(sess) return require_runtime_fallback_allowed(context) store.update_state(sess.session_id, state) async def record_completed_turn( sess: InProcSession, ctx: orchestrator.TurnContext, result: orchestrator.TurnResult, *, context_prefix: str, counselor_turn: TurnRecord | None = None, ) -> None: """상담자 발화와 내담자 응답을 한 번에 기록하고 상태를 갱신한다.""" assert ctx.state_after is not None learner_turn = counselor_turn or TurnRecord( turn_seq=ctx.state_after.turn_seq, speaker="counselor", stage=stage_label(ctx.state_after.stage), text=ctx.learner_text_raw, text_masked=ctx.learner_text_masked, evaluation=result.evaluation, ) await append_completed_turn( sess, learner_turn, context=f"{context_prefix} turn append", ) if result.client_reply: client_mask = guardrail.mask_pii(result.client_reply) await append_completed_turn( sess, TurnRecord( turn_seq=result.turn_seq, speaker="client", stage=stage_label(result.state_after.stage), text=result.client_reply, text_masked=client_mask.text_masked, llm_provider=result.llm_provider, model=result.model, tokens_in=result.tokens_in, tokens_out=result.tokens_out, cost_usd=result.cost_usd, ), context=f"{context_prefix} turn append", ) await update_session_state( sess, result.state_after, context=f"{context_prefix} state update", ) async def record_safety_event( sess: InProcSession, ctx: orchestrator.TurnContext, result: orchestrator.TurnResult, ) -> None: """위기 escalate 시 app.safety_events에 교수자 확인용 알림 레코드를 남긴다.""" crisis = getattr(ctx, "crisis", None) if crisis is None or not getattr(crisis, "escalate", False): return kind = getattr(crisis.kind, "value", None) or str(getattr(crisis, "kind", "crisis")) try: async with db.acquire() as conn: await conn.execute( """ INSERT INTO app.safety_events (session_id, trigger_type, ko_risk_level, escalated, detail) VALUES ($1::uuid, $2, $3, TRUE, $4::jsonb) """, sess.session_id, kind, int(getattr(crisis, "risk_level", 0) or 0), json.dumps({ "matched": list(getattr(crisis, "matched", []) or []), "stage": getattr(result, "stage", None), "turn_seq": getattr(result, "turn_seq", None), "conversation_stopped": getattr(result, "conversation_stopped", False), "crisis_resource": getattr(result, "crisis_resource", None), "alert_status": "teacher_dashboard", }), ) except Exception: pass async def finalize_completed_turn( sess: InProcSession, ctx: orchestrator.TurnContext, result: orchestrator.TurnResult, *, context_prefix: str, counselor_turn: TurnRecord | None = None, ) -> None: """Persist a completed turn and emit any derived safety alert in route-safe order.""" await record_completed_turn( sess, ctx, result, context_prefix=context_prefix, counselor_turn=counselor_turn, ) await record_safety_event(sess, ctx, result) __all__ = [ "SessionAccessError", "append_completed_turn", "finalize_completed_turn", "load_owned_session", "record_safety_event", "record_completed_turn", "stage_label", "update_session_state", ]