"""pocket_context_loader — 카드 브리프 로드 (BaseAgent, LLM 없음). 현재 포켓 카드의 목표/허용범위 + 인물 시트 + 신뢰 레벨별 결을 session.state에 적재한다. 스포일러 필터: 현재 카드 이후의 콘텐츠는 싣지 않는다. """ from __future__ import annotations import os from typing import Any, AsyncGenerator from google.adk.agents import BaseAgent from google.adk.agents.invocation_context import InvocationContext from google.adk.events import Event, EventActions from pydantic import ConfigDict from engine.services.state import HiddenState # P0 0-3 환산 출력 — 신뢰 레벨 → 카르밀라의 '결' (숫자 노출 금지의 구현부) TRUST_TEXTURES = { "trust_high": "떨리는 진심. 가장 다정하고, 꾸밈이 벗겨진 순간이 온다. " "조건이 되면 심층의 말을 꺼낼 수 있다.", "trust_mid": "나른한 다정. 일상 화제가 풍부하고 편안하다. 표면의 고백까지만.", "neutral": "슬픈 미소로 즉답을 피하고, 관심을 상대에게 되돌린다.", "doubt_mid": "언짢음과 경계. 옛 상처가 욱신거린다. '나를 믿지 못하니'가 배어 나온다.", "doubt_high": "집착과 불안. 강압적 설득 직전까지 가되, 그 밑의 외로움이 새어 나온다. " "물리적 돌변은 절대 없다.", } DEFAULT_POCKET_CARD = os.environ.get("POCKET_CARD", "E06-09") def _clean(text: str) -> str: """instruction 치환값 위생 — 중괄호 제거 (state 변수 재해석 방지).""" return text.replace("{", "(").replace("}", ")") class PocketContextLoader(BaseAgent): model_config = ConfigDict(arbitrary_types_allowed=True) repo: Any # ContentRepository — services 경유 없이 읽기 전용 조회만 async def _run_async_impl(self, ctx: InvocationContext) -> AsyncGenerator[Event, None]: state = ctx.session.state card_id = state.get("pocket_card_id", DEFAULT_POCKET_CARD) card = self.repo.get_card(card_id) # 포켓 카드 자신 = 현재 시점 # 신뢰 레벨 환산 (session.state의 숨은 값 기준) hs = HiddenState(trust_score=int(state.get("hidden_trust", 0)), doubt_score=int(state.get("hidden_doubt", 0))) # 카드 브리프: 지문(게이트 반영) + 목표(노트) + 앵커 대사 visible = [b for b in card.narration if b.gate == "all" or (b.gate == "trust_high" and hs.deep_unlock)] # 대사(중앙 대사·앵커)는 자르지 않는다 — 변주의 기준이므로 원문 그대로 lines = [] for b in visible[:10]: if b.type == "dialogue_center": lines.append(f' 핵심 대사: ❝{b.text}❞') elif b.type == "character_dialogue": lines.append(f' {b.speaker or "카르밀라"}: "{b.text}"') else: lines.append(f" {b.text[:150]}") anchors = [f"- {b.speaker or '카르밀라'}: \"{b.text}\"" for b in visible if b.anchor] # 캐릭터용 브리프 — 노트(연출 메타·해금 조건·분기 정보)는 절대 싣지 않는다. # 노트에는 게이트 밖 대사가 인용될 수 있어 캐릭터에게 주면 스포일러 누수가 된다 brief = ( f"장면: {card.id} · {card.title}\n" f"장면 상황(고정 지문):\n" + "\n".join(lines) + ("\n참고 대사 패턴(그대로 쓰지 말고 변주):\n" + "\n".join(anchors) if anchors else "") ) # 디렉터용 노트 — 판정 기준(목표 비트·이탈 범위)이므로 디렉터에게만 간다 note = f"장면 목표(연출 노트): {card.note[:500]}" if card.note else "명시된 노트 없음." carmilla = self.repo.characters.get("carmilla") sheet = "" if carmilla: sheet = (f"{carmilla.display_name} — {carmilla.description}\n" f"기질: {', '.join(carmilla.personality.traits)}\n" f"화법: {', '.join(carmilla.speech.style + carmilla.speech.tone)}\n" + "\n".join(f"제약: {k.value}" for k in carmilla.immutable_constraints if k.value)) delta = { "pocket_card_id": card_id, "pocket_brief": _clean(brief), "pocket_note": _clean(note), "carmilla_sheet": _clean(sheet), "trust_texture": TRUST_TEXTURES[hs.trust_level], "trust_level": hs.trust_level, "deep_unlock": hs.deep_unlock, "guard_directive": state.get("guard_directive", "특이사항 없음."), } yield Event(invocation_id=ctx.invocation_id, author=self.name, actions=EventActions(state_delta=delta)) def create_pocket_context_loader(repo) -> PocketContextLoader: return PocketContextLoader(name="pocket_context_loader", repo=repo)