"""REST/WS 공용 턴 런타임 헬퍼. 세션 로드, 오너십 검증, 완료 턴 영속화, 상태 갱신은 REST 세션 라우트와 음성 WebSocket 라우트가 같은 규칙을 공유해야 한다. """ from __future__ import annotations from enum import Enum import logging from typing import Any, 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 .stage_contract import stage_label from .store import InProcSession, TurnRecord, store logger = logging.getLogger(__name__) _LIVE_COACH_RECHARGE_MIN_RAPPORT = 0.15 _LIVE_COACH_RECHARGE_NEUTRAL_MIN_RAPPORT = 0.35 _LIVE_COACH_RECHARGE_MIN_OPENNESS_GAIN = 0.01 # 페이싱 충전: 평가 신호와 무관하게 N턴마다 1개. 코칭이 가장 필요한(잘 못 풀리는) # 학습자가 3개 소진 후 영영 코칭을 못 받는 순감 구조를 막는다(2026-07-13 회의 후속). _LIVE_COACH_PACING_RECHARGE_EVERY_TURNS = 6 def _role_mask_evaluation_value(value: Any, sess: InProcSession) -> Any: """Role-tokenize evaluator text before normalized rows or cache can persist it.""" if isinstance(value, str): return guardrail.mask_role_identities( value, counselor_identity=sess.learner_label, client_identity=sess.persona.display_name, synthetic_generated=True, ).text_masked if isinstance(value, dict): return { key: _role_mask_evaluation_value(child, sess) for key, child in value.items() } if isinstance(value, list): return [_role_mask_evaluation_value(child, sess) for child in value] if isinstance(value, tuple): return [_role_mask_evaluation_value(child, sess) for child in value] return value 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, ) -> TurnRecord: """상담자 발화와 내담자 응답을 한 번에 기록하고 상태를 갱신한다.""" 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=_role_mask_evaluation_value(result.evaluation, sess), ) if counselor_turn is not None: learner_turn.evaluation = _role_mask_evaluation_value( learner_turn.evaluation, sess, ) await append_completed_turn( sess, learner_turn, context=f"{context_prefix} turn append", ) if result.client_reply: # result.client_reply는 출력 가드레일을 통과한 합성 내담자 발화다. # 입력용의 광범위한 성씨 휴리스틱을 다시 적용하면 "모르겠는데요" 같은 # 일반 용언이 [NAME]으로 오탐되어 저장·재개 화면에 누출된다. client_mask = guardrail.mask_role_identities( result.client_reply, counselor_identity=sess.learner_label, client_identity=sess.persona.display_name, synthetic_generated=True, ) 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", ) return learner_turn 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")) detail = { "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", } try: async with db.acquire(ai_context=True) 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), detail, ) except Exception: logger.exception( "safety event persistence failed: session_id=%s trigger_type=%s", sess.session_id, kind, ) require_runtime_fallback_allowed("safety event") def should_recharge_live_coach_credit( evaluation: dict | None, before: state_machine.SessionState, after: state_machine.SessionState, ) -> tuple[bool, str]: """좋은 발화 충전 + 진행 페이싱 충전 게이트. 기존 3중 AND(appropriateness==pos + rapport>=0.35 + 단계전환/개방도+0.02)는 실사용에서 충전이 사실상 불가능해 코칭이 3턴 만에 죽었다. 완화된 성과 충전에 페이싱 충전을 더해, 잘하는 학습자는 빨리·모두가 주기적으로 충전받게 한다. """ appropriateness = evaluation.get("appropriateness") if isinstance(evaluation, dict) else None rapport = 0.0 if isinstance(evaluation, dict): try: rapport = float(evaluation.get("rapport_signal") or 0) except (TypeError, ValueError): rapport = 0.0 openness_gain = float(after.effective_openness or 0) - float(before.effective_openness or 0) stage_changed = after.stage != before.stage if appropriateness == "pos" and rapport >= _LIVE_COACH_RECHARGE_MIN_RAPPORT: if stage_changed: return True, "좋은 발화로 내담자 단계가 열려 코칭 기회 1개를 충전했습니다." if openness_gain >= _LIVE_COACH_RECHARGE_MIN_OPENNESS_GAIN: return True, "좋은 발화 뒤 내담자 개방도가 올라 코칭 기회 1개를 충전했습니다." if ( appropriateness == "neutral" and rapport >= _LIVE_COACH_RECHARGE_NEUTRAL_MIN_RAPPORT and openness_gain > 0 ): return True, "안정적인 라포 신호로 코칭 기회 1개를 충전했습니다." # 페이싱 충전 — 평가 실패(evaluation=None)여도 회기 진행 자체로 충전된다. turn_seq = int(after.turn_seq or 0) if turn_seq > 0 and turn_seq % _LIVE_COACH_PACING_RECHARGE_EVERY_TURNS == 0: return ( True, f"회기가 {_LIVE_COACH_PACING_RECHARGE_EVERY_TURNS}턴 진행되어 코칭 기회 1개를 충전했습니다.", ) return False, "" async def maybe_recharge_live_coach_credit( sess: InProcSession, ctx: orchestrator.TurnContext, result: orchestrator.TurnResult, ) -> None: """Record one live-coach recharge when a strong learner turn changes client state.""" assert ctx.state_after is not None should_recharge, reason = should_recharge_live_coach_credit( result.evaluation, ctx.state_before, result.state_after, ) if not should_recharge: return try: await session_persistence.record_live_coach_recharge( session_id=sess.session_id, learner_id=sess.learner_id, turn_seq=result.turn_seq, stage=stage_label(result.state_after.stage), reason=reason, ) except Exception: logger.exception( "live coach recharge persistence failed: session_id=%s turn_seq=%s", sess.session_id, result.turn_seq, ) require_runtime_fallback_allowed("live coach recharge") async def finalize_completed_turn( sess: InProcSession, ctx: orchestrator.TurnContext, result: orchestrator.TurnResult, *, context_prefix: str, counselor_turn: TurnRecord | None = None, recharge_live_coach: bool = True, ) -> TurnRecord: """Persist a completed turn and emit any derived safety alert in route-safe order.""" learner_turn = await record_completed_turn( sess, ctx, result, context_prefix=context_prefix, counselor_turn=counselor_turn, ) if recharge_live_coach: await maybe_recharge_live_coach_credit(sess, ctx, result) await record_safety_event(sess, ctx, result) return learner_turn __all__ = [ "SessionAccessError", "append_completed_turn", "finalize_completed_turn", "load_owned_session", "maybe_recharge_live_coach_credit", "record_safety_event", "record_completed_turn", "should_recharge_live_coach_credit", "stage_label", "update_session_state", ]