"""개입 포켓 서비스 — ADK 세션 관리 + run_turn() + 수렴 코드 재검증. 규칙 (CLAUDE.md): - LLM 출력만으로 전이하지 않는다. director의 finish_pocket(state 플래그)을 이 서비스가 재검증하고, 턴 상한 초과 시 강제 수렴한다 - 숨은 상태의 영속 커밋은 close()에서 HiddenState로만 (이중 갱신 금지) """ from __future__ import annotations from dataclasses import dataclass, field from typing import Optional from google.genai import types from engine.app import build_app, build_runner from engine.repositories.content import ContentRepository from engine.services.state import HiddenState USER_ID = "laura" CONVERGE_REASONS = ("goal_reached", "drift", "stall", "turn_cap") @dataclass class PocketTurn: reply: str converged: bool reason: Optional[str] = None turn_no: int = 0 guard_flag: Optional[str] = None @dataclass class PocketLog: card_id: str turns: int = 0 beats_hit: list[str] = field(default_factory=list) reason: Optional[str] = None class PocketService: def __init__(self, repo: ContentRepository, plugins=None, character_model=None, director_model=None): self.repo = repo self.app = build_app(repo, plugins=plugins, character_model=character_model, director_model=director_model) self.runner = build_runner(self.app) self._turns: dict[str, int] = {} # session_id → 턴 수 (서비스가 유일한 권위) # ── 포켓 열기 ─────────────────────────────────────────── async def open(self, card_id: str, hidden: HiddenState, session_id: str | None = None) -> str: card = self.repo.get_card(card_id) if not card.can_chat: raise ValueError(f"{card_id}는 자유채팅 포켓이 아님 (카르밀라 앵커 카드만 허용)") seed = {"pocket_card_id": card_id, "pocket_converged": False, "pocket_beats_hit": [], **hidden.to_session()} session = await self.runner.session_service.create_session( app_name=self.app.name, user_id=USER_ID, session_id=session_id, state=seed) self._turns[session.id] = 0 return session.id # ── 한 턴 ─────────────────────────────────────────────── async def run_turn(self, session_id: str, user_text: str) -> PocketTurn: self._turns[session_id] += 1 turn_no = self._turns[session_id] reply_parts: list[str] = [] msg = types.Content(role="user", parts=[types.Part(text=user_text)]) # 수렴 선언은 턴 단위 — 이전 턴의 (기각된) 선언이 남지 않게 매 턴 초기화 fresh = {"pocket_converged": False, "pocket_converge_reason": None} async for event in self.runner.run_async( user_id=USER_ID, session_id=session_id, new_message=msg, state_delta=fresh): if event.author == "carmilla" and event.content and event.content.parts: reply_parts += [p.text for p in event.content.parts if p.text] session = await self.runner.session_service.get_session( app_name=self.app.name, user_id=USER_ID, session_id=session_id) state = session.state converged, reason = self._verify_convergence(state, turn_no) return PocketTurn(reply="".join(reply_parts).strip(), converged=converged, reason=reason, turn_no=turn_no, guard_flag=state.get("guard_flag")) MIN_TURNS = 3 # 수렴 최소 체류 턴 — 답변 직후 대화가 닫히는 경험 방지 (사유 불문, turn_cap 제외) def _verify_convergence(self, state: dict, turn_no: int) -> tuple[bool, Optional[str]]: """수렴 최종 결정 — 결정적 코드. director 선언은 여기서 재검증된다.""" max_turns = self.repo.chapter.pocket_max_turns if turn_no >= max_turns: return True, "turn_cap" # 무한 체류 방지 — 강제 수렴 if state.get("pocket_converged"): reason = state.get("pocket_converge_reason") if reason not in CONVERGE_REASONS: # 알 수 없는 사유 선언은 기각 return False, None if turn_no < self.MIN_TURNS: return False, None # 성급한 수렴 → 기각 (사유 불문 — 대화가 열리자마자 닫히지 않게) if reason == "goal_reached" and not state.get("pocket_beats_hit"): return False, None # 비트 없이 목표 달성 선언 → 기각 return True, reason return False, None # ── 포켓 닫기: 상태 커밋 + 로그 ───────────────────────── async def close(self, session_id: str, hidden: HiddenState) -> PocketLog: session = await self.runner.session_service.get_session( app_name=self.app.name, user_id=USER_ID, session_id=session_id) state = session.state hidden.commit_from_session(state, source=f"pocket:{state.get('pocket_card_id')}") return PocketLog(card_id=state.get("pocket_card_id", "?"), turns=self._turns.pop(session_id, 0), beats_hit=list(state.get("pocket_beats_hit", [])), reason=state.get("pocket_converge_reason"))