"""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 dataclass, field from decimal import Decimal from typing import Optional from uuid import uuid4 from .services.persona import PersonaCard from .services.state_machine import SessionState DEFAULT_TURN_VISIBLE_TO: tuple[str, ...] = ("client", "counselor", "evaluator") @dataclass(slots=True) class TurnRecord: """발화 1건(② episodic 미러). append-only.""" turn_seq: int speaker: str # 'counselor' | 'client' stage: str text: str # 원문(개발용; 실제 저장은 마스킹본) text_masked: str turn_id: str | None = None # DB app.turns.id. 런타임 폴백 턴은 None. created_at: float = field(default_factory=time.time) llm_provider: str | None = None model: str | None = None tokens_in: int | None = None tokens_out: int | None = None cost_usd: float | Decimal | None = None audio_ref: str | None = None silence_ms: int | None = None speech_rate: float | None = None barge_in: bool | None = None provider_events: list[dict[str, object]] = field(default_factory=list) # fast-loop 턴 평가(TurnEvaluation.to_hook_dict). 학습자(상담자) 발화에 부착. evaluation: Optional[dict] = None visible_to: tuple[str, ...] = DEFAULT_TURN_VISIBLE_TO def is_visible_to(self, role: str) -> bool: return role in (self.visible_to or ()) @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 # 승인된 불변 카드의 DB 식별자. 코드만으로는 같은 코드의 버전 핀을 # 증명할 수 없으므로 런타임 read model에도 복합 키를 보존한다. persona_id: str | None = None persona_version: int | None = None session_no: int = 1 created_at: float = field(default_factory=time.time) ended_at: Optional[float] = None turns: list[TurnRecord] = field(default_factory=list) ended: bool = False prev_rapport_credit: float = 0.0 # carry-over delta 계산용 learner_label: str | None = None goal_stages: list[str] = field(default_factory=list) # 이번 회기 목표 단계(학습자 선택, 최대 4) # 계정 설정 변경과 진행 중 회기의 race를 막는 회기 시작 시점 정책 스냅샷. learner_feedback_enabled: bool = True def turns_visible_to(self, role: str) -> list[TurnRecord]: return [turn for turn in self.turns if turn.is_visible_to(role)] def masked_turns(self, visible_to: str | None = None) -> list[dict[str, str]]: turns = self.turns if visible_to is None else self.turns_visible_to(visible_to) return [{"speaker": t.speaker, "text": t.text_masked} for t in turns] def recent_turns(self, k: int = 6, visible_to: str | None = None) -> list[dict[str, str]]: """최근 K턴 버퍼(L6 직전 맥락). 마스킹본은 masked_turns 단일 소유, 여기선 꼬리 K개만.""" return self.masked_turns(visible_to)[-k:] 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, persona_id: str | None = None, persona_version: int | None = None, session_no: int = 1, carry_rapport: float = 0.0, goal_stages: list[str] | None = None, learner_feedback_enabled: bool = True, ) -> 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, persona_id=persona_id, persona_version=persona_version, session_no=session_no, prev_rapport_credit=carry_rapport, goal_stages=list(goal_stages or []), learner_feedback_enabled=learner_feedback_enabled, ) self._sessions[session_id] = s return s def get(self, session_id: str) -> Optional[InProcSession]: return self._sessions.get(session_id) def put(self, session: InProcSession) -> None: self._sessions[session.session_id] = session def list(self) -> list[InProcSession]: return list(self._sessions.values()) 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 s.ended_at = time.time() return s def remove(self, session_id: str) -> None: self._sessions.pop(session_id, None) # 앱 전역 싱글톤 (DB 없이도 라우트가 바로 쓸 수 있게) store = SessionStore() __all__ = ["DEFAULT_TURN_VISIBLE_TO", "TurnRecord", "InProcSession", "SessionStore", "store"]