"""Counseling session routes. The DB-backed source of truth is still pending, so this route uses the existing in-process session store when DB is degraded. Unlike the previous dev fallback, all browser calls now require a verified server-side auth session and every session operation checks learner ownership. """ from __future__ import annotations import asyncio import json from collections import Counter from datetime import datetime from typing import Literal, Optional from fastapi import APIRouter, HTTPException, status from pydantic import BaseModel, Field from sse_starlette.sse import EventSourceResponse from .. import session_persistence from ..config import settings 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, runtime_fallback_allowed from ..services import evaluator, memory, orchestrator, state_machine from ..store import InProcSession, TurnRecord, store router = APIRouter(prefix="/sessions", tags=["sessions"]) TheoryMode = Literal["humanistic", "cbt", "integrative"] class SessionStartRequest(BaseModel): persona_code: str = Field(..., examples=["P1"]) theory_mode: TheoryMode = "humanistic" class SessionStartResponse(BaseModel): session_id: str case_id: str session_no: int stage: str effective_openness: float recall_summary: Optional[str] = None degraded: bool = False class TurnRequest(BaseModel): text: str = Field(..., min_length=1) class TurnResponse(BaseModel): turn_seq: int stage: str effective_openness: float client_reply: Optional[str] = None safety_flagged: bool = False crisis_kind: str = "none" class SessionEndResponse(BaseModel): session_id: str session_no: int digest_pending: bool end_state: dict class LearnerSessionSummary(BaseModel): session_id: str persona_code: str persona_name: str session_no: int status: Literal["active", "ended"] stage: str turn_count: int learner_turn_count: int client_turn_count: int started_at: str ended_at: str | None = None review_ready: bool = False class LearnerSessionsResponse(BaseModel): source: str = "runtime" sessions: list[LearnerSessionSummary] = Field(default_factory=list) class SessionDetailTurn(BaseModel): turn_seq: int speaker: Literal["learner", "client"] stage: str text: str created_at: str class SessionDetailResponse(BaseModel): session_id: str case_id: str persona_code: str persona_name: str theory_mode: str status: Literal["active", "ended"] stage: str effective_openness: float started_at: str ended_at: str | None = None turns: list[SessionDetailTurn] = Field(default_factory=list) review_ready: bool = False class ReviewClient(BaseModel): name: str initial: str persona: str class ReviewTechnique(BaseModel): kind: str label: str class ReviewNote(BaseModel): author: str tone: str title: str body: str quote: Optional[str] = None class ReviewTurn(BaseModel): id: str ts: str speaker: Literal["learner", "client"] who: str text: str techniques: list[ReviewTechnique] = Field(default_factory=list) note: Optional[ReviewNote] = None class ReviewPhaseSegment(BaseModel): key: str label: str weight: float class ReviewValencePoint(BaseModel): t: float v: float class ReviewRubricRow(BaseModel): name: str cluster: str ratio: float quality: Literal["good", "watch"] freq: str class ReviewPoint(BaseModel): title: str body: str jumpTo: Optional[str] = None class SessionReviewResponse(BaseModel): session_id: str client: ReviewClient date: str durationLabel: str durationSeconds: int reachedPhase: str sessionSignal: str supervisorState: str supervisorName: str summary: str phases: list[ReviewPhaseSegment] = Field(default_factory=list) phaseAxis: list[str] = Field(default_factory=list) valenceAxis: list[str] = Field(default_factory=list) clientValence: list[ReviewValencePoint] = Field(default_factory=list) counselorBaseline: list[ReviewValencePoint] = Field(default_factory=list) turns: list[ReviewTurn] = Field(default_factory=list) rubric: list[ReviewRubricRow] = Field(default_factory=list) goodMoments: list[ReviewPoint] = Field(default_factory=list) growthPoints: list[ReviewPoint] = Field(default_factory=list) nextLine: Optional[str] = None clientFeedback: Optional[str] = None audioUrl: Optional[str] = None pdfExportUrl: Optional[str] = None degraded: bool = True reviewReady: bool = False _RECALL_CACHE: dict[str, memory.RecallContext] = {} _PHASE_KEY_BY_LABEL = { "라포": "rapport", "탐색": "explore", "개입": "intervene", "정리": "closing", } def _stage_label(stage: object) -> str: name = getattr(stage, "name", "") return { "RAPPORT": "라포", "EXPLORE": "탐색", "INTERVENE": "개입", "CLOSE": "정리", }.get(name, str(getattr(stage, "value", stage))) def _ensure_learner(principal: Principal) -> None: if principal.role != Role.LEARNER: raise HTTPException(status.HTTP_403_FORBIDDEN, detail="only learners can use sessions") async def _load_session_or_404( session_id: str, principal: Principal, *, allow_ended: bool = False, ) -> InProcSession: sess = await session_persistence.load_session(session_id, principal, allow_ended=True) if sess is not None: store.put(sess) elif runtime_fallback_allowed(): sess = store.get(session_id) if sess is None: raise HTTPException(status.HTTP_404_NOT_FOUND, detail="session not found") if sess.learner_id != principal.user_id: raise HTTPException(status.HTTP_403_FORBIDDEN, detail="session does not belong to user") if sess.ended and not allow_ended: raise HTTPException(status.HTTP_409_CONFLICT, detail="session already ended") return sess async def _append_session_turn(sess: InProcSession, turn: TurnRecord) -> None: 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("session turn append") store.append_turn(sess.session_id, turn) async def _update_session_state( sess: InProcSession, state: state_machine.SessionState, ) -> None: 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("session state update") store.update_state(sess.session_id, state) async def _end_persisted_session(sess: InProcSession, carry: memory.CarryOver) -> None: if await session_persistence.end_session(sess, carry): sess.ended = True sess.ended_at = datetime.now().timestamp() store.put(sess) return require_runtime_fallback_allowed("session end") store.end(sess.session_id) def _offset_label(seconds: float) -> str: whole = max(0, int(round(seconds))) minutes, sec = divmod(whole, 60) return f"{minutes}:{sec:02d}" def _iso(ts: float | None) -> str | None: if ts is None: return None return datetime.fromtimestamp(ts).isoformat(timespec="seconds") def _duration_label(seconds: int) -> str: if seconds < 60: return f"{seconds}초" minutes, sec = divmod(seconds, 60) return f"{minutes}분 {sec}초" def _client_name(raw: str) -> str: name = raw.split("(", 1)[0].strip() return name or raw.strip() or "내담자" def _review_summary(*, client_name: str, reached_phase: str, turns: list[ReviewTurn]) -> str: if not turns: return ( "아직 실제 발화가 없어 리뷰를 만들 수 없습니다. 회기를 진행한 뒤 종료하면 " "저장된 축어록을 기준으로 리뷰가 표시됩니다." ) learner_count = sum(1 for turn in turns if turn.speaker == "learner") client_count = sum(1 for turn in turns if turn.speaker == "client") return ( f"이 리뷰는 현재 세션에 저장된 실제 축어록 {len(turns)}개를 기반으로 합니다. " f"{client_name}와의 회기는 {reached_phase} 단계까지 진행되었고, " f"학습자 발화 {learner_count}개와 내담자 응답 {client_count}개가 기록되었습니다. " "평가 AI 또는 교수자 코멘트가 아직 생성되지 않은 항목은 빈 상태로 남겨 둡니다." ) def _phase_segments(stage_labels: list[str]) -> list[ReviewPhaseSegment]: counts = Counter(stage_labels) return [ ReviewPhaseSegment( key=_PHASE_KEY_BY_LABEL.get(label, label), label=label, weight=float(count), ) for label, count in counts.items() if count > 0 ] def _clamp_ratio(value: float) -> float: return round(max(0.0, min(1.0, value)), 3) def _compact_text(text: str) -> str: return " ".join(text.split()) def _clip_text(text: str, limit: int = 180) -> str: compact = _compact_text(text) if len(compact) <= limit: return compact return f"{compact[: max(0, limit - 1)].rstrip()}..." def _point_title(text: str, fallback: str) -> str: compact = _clip_text(text, 72) for sep in (".", "。", "!", "?", "\n"): if sep in compact: first = compact.split(sep, 1)[0].strip() if first: return _clip_text(first, 44) return _clip_text(compact, 44) or fallback def _ai_review_points(values: object, *, fallback_prefix: str) -> list[ReviewPoint]: if not isinstance(values, list): return [] points: list[ReviewPoint] = [] for index, value in enumerate(values, start=1): body = _compact_text(str(value or "")) if not body: continue points.append( ReviewPoint( title=_point_title(body, f"{fallback_prefix} {index}"), body=body, jumpTo=None, ) ) return points[:3] def _intent_deviation_points(values: object) -> list[ReviewPoint]: if not isinstance(values, list): return [] points: list[ReviewPoint] = [] for index, value in enumerate(values, start=1): if not isinstance(value, dict): continue dimension = _compact_text(str(value.get("dimension") or f"의도 이탈 {index}")) expected = _compact_text(str(value.get("expected") or "")) actual = _compact_text(str(value.get("actual") or "")) severity = _compact_text(str(value.get("severity") or "minor")) body_parts = [] if expected: body_parts.append(f"기대: {expected}") if actual: body_parts.append(f"실제: {actual}") if severity: body_parts.append(f"심각도: {severity}") if body_parts: points.append( ReviewPoint( title=dimension, body=" · ".join(body_parts), jumpTo=None, ) ) return points[:3] def _rubric_from_evaluation(payload: dict[str, object]) -> list[ReviewRubricRow]: distribution = payload.get("distribution") if not isinstance(distribution, dict): return [] by_category = distribution.get("by_category") if not isinstance(by_category, dict): return [] total = int(distribution.get("total") or 0) if total <= 0: return [] overused = {str(item) for item in distribution.get("overused") or []} underused = {str(item) for item in distribution.get("underused") or []} rows: list[ReviewRubricRow] = [] for category, raw_count in sorted(by_category.items(), key=lambda item: str(item[0])): try: count = int(raw_count) except (TypeError, ValueError): continue code = str(category) watch = code in overused or code in underused rows.append( ReviewRubricRow( name=code.replace("_", " ").title(), cluster="평가 AI 기법 분포", ratio=_clamp_ratio(count / max(1, total)), quality="watch" if watch else "good", freq=f"{count}/{total} labels", ) ) return rows def _review_summary_from_evaluation( *, fallback: str, evaluation_record: dict[str, object] | None, payload: dict[str, object], ) -> str: if not evaluation_record: return fallback status = str(evaluation_record.get("status") or "") if status != "ready": error = _compact_text(str(evaluation_record.get("error") or payload.get("error") or "")) return ( "저장된 축어록은 확인했지만 평가 AI 산출물이 아직 준비되지 않았습니다. " + (f"사유: {error}" if error else "평가가 완료되면 코칭 항목이 갱신됩니다.") ) rationale = _compact_text(str(payload.get("supervisor_rationale") or "")) critique = _compact_text(str(payload.get("supervisor_critique") or "")) evaluated = payload.get("turns_evaluated") prefix = f"평가 AI가 학습자 발화 {evaluated}개를 deep-loop로 분석했습니다. " details = " ".join(part for part in [rationale, critique] if part) return prefix + (details if details else "아래 코칭 항목은 저장된 축어록과 평가 AI 결과를 기준으로 합니다.") def _next_line_from_evaluation(payload: dict[str, object]) -> str | None: alternatives = payload.get("alternative_utterances") if not isinstance(alternatives, list): return None for value in alternatives: line = _compact_text(str(value or "")) if line: return line return None def _latest_client_feedback(turns: list[ReviewTurn]) -> str | None: for turn in reversed(turns): if turn.speaker == "client": return _clip_text(turn.text) return None def _evaluation_payload(record: dict[str, object] | None) -> dict[str, object]: if not record: return {} payload = record.get("payload") return payload if isinstance(payload, dict) else {} async def _generate_and_save_session_evaluation(sess: InProcSession) -> None: if not sess.turns: return 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) try: result = await asyncio.wait_for( evaluator.evaluate_session( session_id=sess.session_id, stage=_stage_label(sess.state.stage), masked_turns=enriched, engine=engine_client, technique_codes=[], theory_mode=sess.theory_mode, scope="session_end", ), timeout=min(float(settings.engine_timeout), 45.0), ) status_value = "error" if result.error else "ready" await session_persistence.save_session_evaluation( session_id=sess.session_id, learner_id=sess.learner_id, status=status_value, source="engine", scope=result.scope, stage=result.stage, payload=result.to_dict(), error=result.error, ) except Exception as exc: await session_persistence.save_session_evaluation( session_id=sess.session_id, learner_id=sess.learner_id, status="error", source="engine", scope="session_end", stage=_stage_label(sess.state.stage), payload={}, error=str(exc), ) def _schedule_session_evaluation(sess: InProcSession) -> None: if not sess.turns: return asyncio.create_task(_generate_and_save_session_evaluation(sess)) def _learner_summary(sess: InProcSession, *, review_ready: bool = False) -> LearnerSessionSummary: 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") return LearnerSessionSummary( session_id=sess.session_id, 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), learner_turn_count=learner_turns, client_turn_count=client_turns, started_at=_iso(sess.created_at) or "", ended_at=_iso(sess.ended_at), review_ready=review_ready, ) async def _review_ready(sess: InProcSession, principal: Principal) -> bool: if not sess.ended or not sess.turns: return False evaluation_record, _ = await session_persistence.load_session_evaluation( sess.session_id, principal, ) return bool(evaluation_record and evaluation_record.get("status") == "ready") def _session_detail( sess: InProcSession, *, review_ready: bool = False, ) -> SessionDetailResponse: return SessionDetailResponse( session_id=sess.session_id, case_id=sess.case_id, persona_code=sess.persona_code, persona_name=sess.persona.display_name, theory_mode=sess.theory_mode, status="ended" if sess.ended else "active", stage=_stage_label(sess.state.stage), effective_openness=round(sess.state.effective_openness, 4), started_at=_iso(sess.created_at) or "", ended_at=_iso(sess.ended_at), turns=[ SessionDetailTurn( turn_seq=turn.turn_seq, speaker="learner" if turn.speaker == "counselor" else "client", stage=turn.stage, text=turn.text_masked, created_at=_iso(turn.created_at) or "", ) for turn in sess.turns ], review_ready=review_ready, ) @router.get("", response_model=LearnerSessionsResponse) async def list_learner_sessions(principal: CurrentPrincipal) -> LearnerSessionsResponse: """Return the current learner's real practice sessions.""" _ensure_learner(principal) sessions, durable = await session_persistence.list_sessions(principal) if not durable: require_runtime_fallback_allowed("session list") sessions = [ sess for sess in store.list() if sess.learner_id == principal.user_id ] sessions.sort(key=lambda sess: sess.created_at, reverse=True) summaries: list[LearnerSessionSummary] = [] for sess in sessions[:20]: summaries.append(_learner_summary(sess, review_ready=await _review_ready(sess, principal))) return LearnerSessionsResponse( source="database" if durable else "runtime", sessions=summaries, ) @router.get("/{session_id}", response_model=SessionDetailResponse) async def get_session_detail( session_id: str, principal: CurrentPrincipal, ) -> SessionDetailResponse: """Return a learner-owned session with transcript for resume/history.""" _ensure_learner(principal) sess = await _load_session_or_404(session_id, principal, allow_ended=True) return _session_detail(sess, review_ready=await _review_ready(sess, principal)) @router.post("", response_model=SessionStartResponse, status_code=status.HTTP_201_CREATED) async def start_session( body: SessionStartRequest, principal: CurrentPrincipal, ) -> SessionStartResponse: """Start a learner-owned practice session.""" _ensure_learner(principal) try: catalog_persona = await get_catalog_persona(body.persona_code) except Exception as exc: raise HTTPException( status.HTTP_503_SERVICE_UNAVAILABLE, detail="persona catalog database unavailable", ) from exc if catalog_persona is None: raise HTTPException(status.HTTP_404_NOT_FOUND, detail=f"unknown persona {body.persona_code}") card = catalog_persona.card recall = memory.build_recall_context() st = state_machine.init_state( base_resistance=card.base_resistance(), unlock_rate=card.unlock_rate(), decay_floor=card.decay_floor(), ideation_baseline=card.ideation_baseline(), carry=recall.carry, ) carry_rapport = st.rapport_credit sess = await session_persistence.create_session( learner_id=principal.user_id, card=card, theory_mode=body.theory_mode, state=st, session_no=1, carry_rapport=carry_rapport, persona_id=catalog_persona.persona_id, persona_version=catalog_persona.version, ) degraded = catalog_persona.degraded or sess is None if sess is None: require_runtime_fallback_allowed("session creation") sess = store.create( learner_id=principal.user_id, persona=card, theory_mode=body.theory_mode, state=st, session_no=1, carry_rapport=carry_rapport, ) else: store.put(sess) _RECALL_CACHE[sess.session_id] = recall return SessionStartResponse( session_id=sess.session_id, case_id=sess.case_id, session_no=sess.session_no, stage=_stage_label(st.stage), effective_openness=round(st.effective_openness, 4), recall_summary=recall.recall_summary, degraded=degraded, ) @router.get("/{session_id}/review", response_model=SessionReviewResponse) async def get_session_review( session_id: str, principal: CurrentPrincipal, ) -> SessionReviewResponse: """Return a learner-safe review built only from the stored session transcript.""" _ensure_learner(principal) sess = await _load_session_or_404(session_id, principal, allow_ended=True) end_ts = sess.ended_at or datetime.now().timestamp() duration_seconds = max(0, int(round(end_ts - sess.created_at))) client_name = _client_name(sess.persona.display_name) client_initial = client_name[:1] or "내" reached_phase = _stage_label(sess.state.stage) stage_labels = [turn.stage for turn in sess.turns] or [reached_phase] axis = ["0:00"] if duration_seconds > 0: axis.append(_offset_label(duration_seconds)) evaluation_record, evaluation_durable = await session_persistence.load_session_evaluation( session_id, principal, ) evaluation_payload = _evaluation_payload(evaluation_record) evaluation_status = str(evaluation_record.get("status") or "") if evaluation_record else "" evaluation_ready = evaluation_status == "ready" first_turn_ts = sess.turns[0].created_at if sess.turns else sess.created_at turns: list[ReviewTurn] = [] for index, turn in enumerate(sess.turns): speaker: Literal["learner", "client"] = ( "learner" if turn.speaker == "counselor" else "client" ) turns.append( ReviewTurn( id=f"t{index + 1}", ts=_offset_label(turn.created_at - first_turn_ts), speaker=speaker, who="학습자" if speaker == "learner" else client_name, text=turn.text_masked, techniques=[], note=None, ) ) if not turns: session_signal = "기록 없음" elif sess.ended: session_signal = "종료됨" else: session_signal = "진행 중" transcript_summary = _review_summary( client_name=client_name, reached_phase=reached_phase, turns=turns, ) rubric: list[ReviewRubricRow] = [] good_moments: list[ReviewPoint] = [] growth_points: list[ReviewPoint] = [] next_line: str | None = None if evaluation_ready: rubric = _rubric_from_evaluation(evaluation_payload) good_moments = _ai_review_points( evaluation_payload.get("strengths"), fallback_prefix="강점", ) growth_points = _ai_review_points( evaluation_payload.get("improvements"), fallback_prefix="개선점", ) if not growth_points: growth_points = _intent_deviation_points(evaluation_payload.get("intent_deviations")) next_line = _next_line_from_evaluation(evaluation_payload) client_feedback = _latest_client_feedback(turns) review_degraded = bool(turns) and not evaluation_ready if evaluation_ready: supervisor_state = "평가 완료" elif evaluation_status == "error": supervisor_state = "평가 실패" elif turns: supervisor_state = "평가 대기" else: supervisor_state = "기록 대기" summary = _review_summary_from_evaluation( fallback=transcript_summary, evaluation_record=evaluation_record, payload=evaluation_payload, ) if evaluation_record and not evaluation_durable: summary += " 현재 평가는 런타임 캐시에서 복원되었습니다." return SessionReviewResponse( session_id=session_id, client=ReviewClient( name=client_name, initial=client_initial, persona=f"{sess.persona_code} · {sess.persona.difficulty}", ), date=datetime.fromtimestamp(sess.created_at).strftime("%Y-%m-%d"), durationLabel=_duration_label(duration_seconds), durationSeconds=duration_seconds, reachedPhase=reached_phase, sessionSignal=session_signal, supervisorState=supervisor_state, supervisorName="AI", summary=summary, phases=_phase_segments(stage_labels), phaseAxis=axis, valenceAxis=axis, clientValence=[], counselorBaseline=[], turns=turns, rubric=rubric, goodMoments=good_moments, growthPoints=growth_points, nextLine=next_line, clientFeedback=client_feedback, audioUrl=None, pdfExportUrl=None, degraded=review_degraded, reviewReady=evaluation_ready, ) @router.post("/{session_id}/turn", response_model=TurnResponse) async def submit_turn( session_id: str, body: TurnRequest, principal: CurrentPrincipal, ) -> TurnResponse: """Submit one trainee utterance and return the generated client reply.""" _ensure_learner(principal) sess = await _load_session_or_404(session_id, principal) recall = _RECALL_CACHE.get(session_id) or memory.RecallContext() ctx = orchestrator.prepare_turn( session_id=session_id, case_id=sess.case_id, card=sess.persona, state=sess.state, learner_text=body.text, recall_summary=recall.recall_summary, pinned_facts=recall.pinned_facts, recent_turns=sess.recent_turns(), ) assert ctx.state_after is not None try: result = await orchestrator.run_turn_generate(ctx, engine_client) except EngineError as exc: raise HTTPException( status.HTTP_503_SERVICE_UNAVAILABLE, detail=f"engine unavailable: {exc}", ) from exc await _append_session_turn( sess, TurnRecord( turn_seq=ctx.state_after.turn_seq, speaker="counselor", stage=_stage_label(ctx.state_after.stage), text=body.text, text_masked=ctx.learner_text_masked, ), ) if result.client_reply: await _append_session_turn( sess, TurnRecord( turn_seq=result.turn_seq, speaker="client", stage=_stage_label(result.state_after.stage), text=result.client_reply, text_masked=result.client_reply, ), ) await _update_session_state(sess, result.state_after) return TurnResponse( turn_seq=result.turn_seq, stage=_stage_label(result.state_after.stage), effective_openness=round(result.effective_openness, 4), client_reply=result.client_reply, safety_flagged=result.safety_flagged, crisis_kind=result.crisis_kind, ) @router.post("/{session_id}/stream") async def stream_turn( session_id: str, body: TurnRequest, principal: CurrentPrincipal, ): """Stream a generated client reply for one trainee utterance.""" _ensure_learner(principal) sess = await _load_session_or_404(session_id, principal) recall = _RECALL_CACHE.get(session_id) or memory.RecallContext() ctx = orchestrator.prepare_turn( session_id=session_id, case_id=sess.case_id, card=sess.persona, state=sess.state, learner_text=body.text, recall_summary=recall.recall_summary, pinned_facts=recall.pinned_facts, recent_turns=sess.recent_turns(), ) assert ctx.state_after is not None async def event_generator(): last_beat = asyncio.get_running_loop().time() final_reply = "" try: async for ev in orchestrator.run_turn_stream(ctx, engine_client): if ev.event == "token": text = str(ev.data.get("text", "")) final_reply += text yield {"event": "token", "data": text} elif ev.event == "done": data = {**ev.data, "stage": _stage_label(ctx.state_after.stage)} await _append_session_turn( sess, TurnRecord( turn_seq=ctx.state_after.turn_seq, speaker="counselor", stage=_stage_label(ctx.state_after.stage), text=body.text, text_masked=ctx.learner_text_masked, ), ) await _update_session_state(sess, ctx.state_after) if final_reply: await _append_session_turn( sess, TurnRecord( turn_seq=ctx.state_after.turn_seq, speaker="client", stage=_stage_label(ctx.state_after.stage), text=final_reply, text_masked=final_reply, ), ) yield {"event": "done", "data": json.dumps(data, ensure_ascii=False)} else: yield {"event": ev.event, "data": json.dumps(ev.data, ensure_ascii=False)} now = asyncio.get_running_loop().time() if now - last_beat >= settings.sse_heartbeat_seconds: yield {"event": "ping", "data": "{}"} last_beat = now except Exception as exc: yield {"event": "error", "data": json.dumps({"detail": str(exc)}, ensure_ascii=False)} return return EventSourceResponse(event_generator()) @router.post("/{session_id}/end", response_model=SessionEndResponse) async def end_session( session_id: str, principal: CurrentPrincipal, ) -> SessionEndResponse: """End a learner-owned session and prepare carry-over state.""" _ensure_learner(principal) sess = await _load_session_or_404(session_id, principal, allow_ended=True) recall = _RECALL_CACHE.get(session_id) or memory.RecallContext() carry = memory.make_carry_over( state=sess.state, session_id=session_id, case_id=sess.case_id, session_no=sess.session_no, masked_turns=sess.masked_turns(), prev_rapport_credit=sess.prev_rapport_credit, open_threads=recall.open_threads, ) await _end_persisted_session(sess, carry) _RECALL_CACHE.pop(session_id, None) _schedule_session_evaluation(sess) return SessionEndResponse( session_id=session_id, session_no=sess.session_no, digest_pending=carry.compression_job is not None, end_state=carry.end_state, )