"""Docker 없이도 도는 in-memory 세션/턴 스토어 (DB degraded 폴백). DB(NAS Postgres)가 단일 SoR 이지만(db.py), Docker off 개발/시연에서도 엔진만 떠 있으면 상담 1턴이 돌아야 한다. 이 모듈은 app.sessions / app.session_state / app.turns 의 *최소 in-proc 미러*를 제공한다. DB 가 붙으면 라우트가 DB 경로로 전환한다(교체 대상). 스레드/동시성: uvicorn 단일 프로세스 가정의 단순 dict. 멀티워커 시엔 DB 가 SoR 이므로 무방. """ from __future__ import annotations import time from dataclasses import asdict, dataclass, field from typing import Optional from uuid import uuid4 from .services.persona import PersonaCard from .services.state_machine import SessionState @dataclass(slots=True) class TurnRecord: """발화 1건(② episodic 미러). append-only.""" turn_seq: int speaker: str # 'counselor' | 'client' stage: str text: str # 원문(개발용; 실제 저장은 마스킹본) text_masked: str created_at: float = field(default_factory=time.time) @dataclass(slots=True) class InProcSession: """① working + 메타 + 페르소나 핀(in-proc).""" session_id: str case_id: str learner_id: str persona_code: str theory_mode: str persona: PersonaCard state: SessionState session_no: int = 1 turns: list[TurnRecord] = field(default_factory=list) ended: bool = False prev_rapport_credit: float = 0.0 # carry-over delta 계산용 def recent_turns(self, k: int = 6) -> list[dict[str, str]]: """최근 K턴 버퍼(L6 직전 맥락). 마스킹본 사용.""" return [{"speaker": t.speaker, "text": t.text_masked} for t in self.turns[-k:]] def masked_turns(self) -> list[dict[str, str]]: return [{"speaker": t.speaker, "text": t.text_masked} for t in self.turns] class SessionStore: """in-memory 세션 레지스트리. DB degraded 시 SoR 대용.""" def __init__(self) -> None: self._sessions: dict[str, InProcSession] = {} def create( self, *, learner_id: str, persona: PersonaCard, theory_mode: str, state: SessionState, session_no: int = 1, carry_rapport: float = 0.0, ) -> InProcSession: session_id = uuid4().hex case_id = uuid4().hex s = InProcSession( session_id=session_id, case_id=case_id, learner_id=learner_id, persona_code=persona.code, theory_mode=theory_mode, persona=persona, state=state, session_no=session_no, prev_rapport_credit=carry_rapport, ) self._sessions[session_id] = s return s def get(self, session_id: str) -> Optional[InProcSession]: return self._sessions.get(session_id) def append_turn(self, session_id: str, turn: TurnRecord) -> None: s = self._sessions.get(session_id) if s is not None: s.turns.append(turn) def update_state(self, session_id: str, state: SessionState) -> None: s = self._sessions.get(session_id) if s is not None: s.state = state def end(self, session_id: str) -> Optional[InProcSession]: s = self._sessions.get(session_id) if s is not None: s.ended = True return s def remove(self, session_id: str) -> None: self._sessions.pop(session_id, None) # 앱 전역 싱글톤 (DB 없이도 라우트가 바로 쓸 수 있게) store = SessionStore() __all__ = ["TurnRecord", "InProcSession", "SessionStore", "store"]