"""상담 세션 라우트 — 시작 / 턴 / 스트림 / 종료 (services 실호출). 흐름 (설계서 §2 회기 라이프사이클 + 마스터플랜 §2.2 턴 사이클): POST /sessions — 회기 시작 (페르소나 핀 + 회상 + 상태머신 init) POST /sessions/{id}/turn — 수련생 발화 1턴 (가드레일→상태머신→내담자AI→출력가드) GET /sessions/{id}/stream — 내담자 AI 응답 SSE 스트림 (heartbeat 포함) POST /sessions/{id}/end — 회기 종료 (무손실 carry-over + 압축 트리거) DB(NAS Postgres)가 SoR 이지만 Docker off 에서도 엔진만 떠 있으면 1턴이 돌도록 **store(in-memory)** 폴백을 둔다(degraded). 인증도 dev 폴백을 허용한다(개발 편의). 상태머신(라포→탐색→개입→정리)은 백엔드가 결정론적으로 소유(LLM 아님, 마스터플랜 §0). """ from __future__ import annotations import asyncio import json from typing import Annotated, Literal, Optional from fastapi import APIRouter, Depends, HTTPException, status from pydantic import BaseModel, Field from sse_starlette.sse import EventSourceResponse from fastapi import Cookie from ..config import settings from ..deps import Principal, Role from ..engine_client import EngineError, engine_client from ..services import memory, orchestrator, persona, state_machine from ..store import TurnRecord, store router = APIRouter(prefix="/sessions", tags=["sessions"]) StageLiteral = Literal["라포", "탐색", "개입", "정리"] # ── 인증 — dev 폴백 허용 (쿠키 없으면 dev learner) ─────────────────────────── async def get_principal_dev( session_cookie: Annotated[Optional[str], Cookie(alias="__Host-vignette_sid")] = None, ) -> Principal: """세션 쿠키 → Principal. 미인증(쿠키 없음)이면 dev learner 폴백. 개발/시연(쿠키 없음, DB off)에서도 상담 루프가 돌게 한다. prod 에선 auth.py BFF + Redis 세션이 완성되면 deps.get_current_principal 로 교체. TODO: Redis 세션 룩업으로 user_id/role/cohort 복원. """ if not session_cookie: return Principal(user_id="dev-learner", role=Role.LEARNER, cohort_ids=[]) # TODO: Redis 세션 검증. 현재는 쿠키 존재만으로 dev learner. return Principal(user_id="dev-user", role=Role.LEARNER, cohort_ids=[]) DevPrincipal = Annotated[Principal, Depends(get_principal_dev)] # ── 요청/응답 모델 ────────────────────────────────────── class SessionStartRequest(BaseModel): persona_code: str = Field(..., examples=["P1"]) # 시드 페르소나 (P1/P2/P3) theory_mode: Literal["humanistic", "cbt", "integrative"] = "humanistic" class SessionStartResponse(BaseModel): session_id: str case_id: str session_no: int stage: StageLiteral effective_openness: float recall_summary: Optional[str] = None degraded: bool = False # DB 미가용 in-proc 모드 여부(시연 투명성) class TurnRequest(BaseModel): text: str = Field(..., min_length=1) # 수련생 발화 (저장 전 PII 마스킹) class TurnResponse(BaseModel): turn_seq: int stage: StageLiteral 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 # 압축은 비동기 비블로킹 (설계서 §2-C) end_state: dict # ════════════════════════════════════════════════════════════════════════════ # 회기 시작 # ════════════════════════════════════════════════════════════════════════════ @router.post("", response_model=SessionStartResponse, status_code=status.HTTP_201_CREATED) async def start_session( body: SessionStartRequest, principal: DevPrincipal, ) -> SessionStartResponse: """회기 시작 — 페르소나 핀 + 회상 + 결정론 상태 init (설계서 §2-A). DB 가용 시: persona_card(approved) 조회 + case_profile/직전 summary 회상. DB 미가용(degraded): 시드 페르소나(persona.SEED) + 빈 회상(첫 회기)으로 in-proc. """ card = persona.get_seed_persona(body.persona_code) if card is None: # TODO: DB app.persona_card WHERE code=$1 AND status='approved' 조회 경로 raise HTTPException(status.HTTP_404_NOT_FOUND, detail=f"unknown persona {body.persona_code}") # 회상 — DB/RAG 미가용 시 빈 컨텍스트(첫 회기). 가용 시 case_digest/summary/episodic 주입. # TODO(Phase 2a): memory.build_recall_context(case_digest=..., prev_summary=..., episodic_snippets=...) recall = memory.build_recall_context() # 결정론 상태 init (carry-over 가 있으면 이월; 첫 회기는 None) 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, ) sess = store.create( learner_id=principal.user_id, persona=card, theory_mode=body.theory_mode, state=st, session_no=1, ) # 회상 핀(pinned facts)을 세션에 묶어 둔다(턴마다 재조립). store 는 간단히 state 만 보유하므로 # recall_summary/pinned 는 in-proc 캐시로 별도 보관. _RECALL_CACHE[sess.session_id] = recall return SessionStartResponse( session_id=sess.session_id, case_id=sess.case_id, session_no=sess.session_no, stage=st.stage.value, # type: ignore[arg-type] effective_openness=round(st.effective_openness, 4), recall_summary=recall.recall_summary, degraded=True, # 현재 in-proc 경로(DB 붙으면 False 분기) ) # 회상 컨텍스트 in-proc 캐시 (회기 내 재사용, recall_context). DB 붙으면 session_state.recall_context. _RECALL_CACHE: dict[str, memory.RecallContext] = {} def _load_session_or_404(session_id: str): sess = store.get(session_id) if sess is None: raise HTTPException(status.HTTP_404_NOT_FOUND, detail="session not found") if sess.ended: raise HTTPException(status.HTTP_409_CONFLICT, detail="session already ended") return sess # ════════════════════════════════════════════════════════════════════════════ # 턴 (동기 폴백 — 기본 UX 는 /stream) # ════════════════════════════════════════════════════════════════════════════ @router.post("/{session_id}/turn", response_model=TurnResponse) async def submit_turn( session_id: str, body: TurnRequest, principal: DevPrincipal, ) -> TurnResponse: """수련생 발화 1턴 (마스터플랜 §2.2 / 설계서 §2-B). 오케스트레이터로 1~8단계 결정론 파이프라인 실행. 내담자 응답은 동기로 한 번에 받는다 (기본 UX 는 GET /stream 토큰 스트리밍; 이 경로는 폴백/테스트). """ sess = _load_session_or_404(session_id) 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(), ) # 수련생 발화 로깅(② episodic 미러) — 마스킹본 저장 assert ctx.state_after is not None store.append_turn( session_id, TurnRecord( turn_seq=ctx.state_after.turn_seq, speaker="counselor", stage=ctx.state_after.stage.value, text=body.text, text_masked=ctx.learner_text_masked, ), ) try: result = await orchestrator.run_turn_generate(ctx, engine_client) except EngineError as e: raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, detail=f"engine unavailable: {e}") # 내담자 응답 로깅 + 상태 체크포인트(① working UPSERT 미러) if result.client_reply: store.append_turn( session_id, TurnRecord( turn_seq=result.turn_seq, speaker="client", stage=result.stage, text=result.client_reply, text_masked=result.client_reply, # 내담자 응답은 합성(원문PII 없음) ), ) store.update_state(session_id, result.state_after) return TurnResponse( turn_seq=result.turn_seq, stage=result.stage, # type: ignore[arg-type] effective_openness=round(result.effective_openness, 4), client_reply=result.client_reply, safety_flagged=result.safety_flagged, crisis_kind=result.crisis_kind, ) # ════════════════════════════════════════════════════════════════════════════ # 스트림 (기본 UX — SSE 토큰) # ════════════════════════════════════════════════════════════════════════════ @router.post("/{session_id}/stream") async def stream_turn( session_id: str, body: TurnRequest, principal: DevPrincipal, ): """수련생 발화 1턴을 받아 내담자 AI 응답을 SSE 토큰 스트림으로 흘린다. - Cloudflare 100초 timeout 회피: settings.sse_heartbeat_seconds 마다 ping [R2] - 오케스트레이터 run_turn_stream(가드레일·상태머신·페르소나·출력가드 적용)을 프록시 - 이벤트: token | ping | safety | done | error """ sess = _load_session_or_404(session_id) 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 # 수련생 발화 로깅 + 상태 체크포인트(스트림은 응답 전 상태 갱신 — 결정론이라 무방) store.append_turn( session_id, TurnRecord( turn_seq=ctx.state_after.turn_seq, speaker="counselor", stage=ctx.state_after.stage.value, text=body.text, text_masked=ctx.learner_text_masked, ), ) store.update_state(session_id, ctx.state_after) async def event_generator(): last_beat = asyncio.get_event_loop().time() final_reply = "" try: async for ev in orchestrator.run_turn_stream(ctx, engine_client): if ev.event == "token": final_reply += ev.data.get("text", "") yield {"event": ev.event, "data": json.dumps(ev.data, ensure_ascii=False)} now = asyncio.get_event_loop().time() if now - last_beat >= settings.sse_heartbeat_seconds: yield {"event": "ping", "data": "{}"} last_beat = now except Exception as e: # 방어 — 어떤 예외도 SSE error 프레임으로 yield {"event": "error", "data": json.dumps({"detail": str(e)}, ensure_ascii=False)} return # 내담자 응답 로깅(② episodic) — 스트림 종료 후 if final_reply: store.append_turn( session_id, TurnRecord( turn_seq=ctx.state_after.turn_seq, speaker="client", stage=ctx.state_after.stage.value, text=final_reply, text_masked=final_reply, ), ) return EventSourceResponse(event_generator()) # ════════════════════════════════════════════════════════════════════════════ # 회기 종료 # ════════════════════════════════════════════════════════════════════════════ @router.post("/{session_id}/end", response_model=SessionEndResponse) async def end_session( session_id: str, principal: DevPrincipal, ) -> SessionEndResponse: """회기 종료 — 무손실 carry-over + 압축 트리거 (설계서 §2-C, 비동기 비블로킹). (A) 무손실 carry-over: end_state = state.snapshot() (코드 복사, LLM 미경유) [P4] (C) narrative 압축(LLM)은 CompressionJob 으로 큐잉(여기선 페이로드만; 실제 호출은 후속 워커) """ sess = store.get(session_id) if sess is None: raise HTTPException(status.HTTP_404_NOT_FOUND, detail="session not found") 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, ) # TODO(Phase 2a): BackgroundTasks 로 carry.compression_job 을 # engine_client.generate(GenerateRequest(ai_role='evaluator', tier='feedback', # messages=memory.build_compression_messages(job))) 호출 → session_summary UPSERT + 임베딩. # 현재는 큐잉만(digest_pending=True). DB 없으면 압축 결과 적재 생략. store.end(session_id) _RECALL_CACHE.pop(session_id, None) return SessionEndResponse( session_id=session_id, session_no=sess.session_no, digest_pending=carry.compression_job is not None, end_state=carry.end_state, )