diff --git a/engine/.adk/session.db b/engine/.adk/session.db new file mode 100644 index 0000000000000000000000000000000000000000..74134f419c2f83e9363292a8a9debbd9705e9859 Binary files /dev/null and b/engine/.adk/session.db differ diff --git a/engine/__init__.py b/engine/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/engine/__pycache__/__init__.cpython-312.pyc b/engine/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..eab2b37eb19e6b5f90a5c58d3f578f13da579fba Binary files /dev/null and b/engine/__pycache__/__init__.cpython-312.pyc differ diff --git a/engine/__pycache__/__init__.cpython-314.pyc b/engine/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ae837b02b6d29c579158f875e44386a455ff6bdb Binary files /dev/null and b/engine/__pycache__/__init__.cpython-314.pyc differ diff --git a/engine/__pycache__/agent.cpython-312.pyc b/engine/__pycache__/agent.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4e12ba1c970c990b00b89b6c35ee317e3631d86f Binary files /dev/null and b/engine/__pycache__/agent.cpython-312.pyc differ diff --git a/engine/__pycache__/agent.cpython-314.pyc b/engine/__pycache__/agent.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2f4c8e7af7382f2f21541d29ff9cbe58769f61d6 Binary files /dev/null and b/engine/__pycache__/agent.cpython-314.pyc differ diff --git a/engine/__pycache__/app.cpython-314.pyc b/engine/__pycache__/app.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..379f5a6f6ef4948ecfe329443c8c7bc88b6ea7dd Binary files /dev/null and b/engine/__pycache__/app.cpython-314.pyc differ diff --git a/engine/__pycache__/model.cpython-312.pyc b/engine/__pycache__/model.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e13f9d7c32e2c6f3e8de9e19411033bff37d1ae9 Binary files /dev/null and b/engine/__pycache__/model.cpython-312.pyc differ diff --git a/engine/__pycache__/model.cpython-314.pyc b/engine/__pycache__/model.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..59e4326035929caf3666e28036c3d7bce8889f2d Binary files /dev/null and b/engine/__pycache__/model.cpython-314.pyc differ diff --git a/engine/__pycache__/router.cpython-314.pyc b/engine/__pycache__/router.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5c83973f4abb28f1055379f5d5ecd4ee7008fe7e Binary files /dev/null and b/engine/__pycache__/router.cpython-314.pyc differ diff --git a/engine/agent.py b/engine/agent.py new file mode 100644 index 0000000000000000000000000000000000000000..b259e7e4e5f4591839c19d1ee9530706b0aca809 --- /dev/null +++ b/engine/agent.py @@ -0,0 +1,50 @@ +"""루트 에이전트 — 개입 포켓 턴 파이프라인 조립 (팩토리는 조립만 담당). + + pocket_session (SequentialAgent) — 유저 메시지 1건 = 1회 실행 + ├── pocket_context_loader ← BaseAgent: 카드 브리프/결 적재 (스포일러 필터) + ├── input_guard ← BaseAgent: 정체직격/약점/메타 감지 → 지시 생성 + ├── carmilla ← LlmAgent: 캐릭터 응답 + └── director ← LlmAgent: 축/비트/수렴 판정 (도구로 state 기록) + +주: CLAUDE.md의 turn_loop(LoopAgent) 기준 구조는 씬 전체를 1회 호출로 도는 +자율 루프용이다. 사람이 턴마다 입력하는 채팅은 Runner의 표준 패턴대로 +"유저 메시지마다 파이프라인 1회 실행"이 ADK 공식 흐름이며, 턴 상한·수렴 +재검증은 services/pocket.py의 결정적 코드가 담당한다. +director가 마지막이므로 finish_pocket의 escalate가 삼킬 후속 에이전트는 없다. + +`adk web /path/to/sorac` 실행 시 이 모듈의 root_agent가 노출된다 (앱 이름: engine). +""" + +from __future__ import annotations + +from pathlib import Path + +from google.adk.agents import SequentialAgent + +from engine.agents.input_guard import create_input_guard +from engine.agents.pocket_context_loader import create_pocket_context_loader +from engine.repositories.content import ContentRepository +from engine.sub_agents.character.agent import create_character +from engine.sub_agents.director.agent import create_director + +_CONTENT_DIR = Path(__file__).resolve().parent.parent / "content" + + +def create_pocket_pipeline(repo: ContentRepository | None = None, + character_model=None, director_model=None) -> SequentialAgent: + # ADK 2.3이 SequentialAgent에 deprecation(→ google.adk.workflow)을 띄우지만, + # Workflow는 그래프 기반 신 API라 CLAUDE.md의 에이전트 규칙과 함께 별도 검토 후 + # 마이그레이션한다 (follow-up). 현행 공식 릴리스에서 SequentialAgent는 정상 동작. + repo = repo or ContentRepository(_CONTENT_DIR) + return SequentialAgent( + name="pocket_session", + sub_agents=[ + create_pocket_context_loader(repo), + create_input_guard(repo), + create_character(character_model), + create_director(director_model), + ], + ) + + +root_agent = create_pocket_pipeline() diff --git a/engine/agents/__init__.py b/engine/agents/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/engine/agents/__pycache__/__init__.cpython-312.pyc b/engine/agents/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1abef43b419dc16b22ee85fcfac29a1c628e240c Binary files /dev/null and b/engine/agents/__pycache__/__init__.cpython-312.pyc differ diff --git a/engine/agents/__pycache__/__init__.cpython-314.pyc b/engine/agents/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..06633ca1e6281f676bb3296b39fdcc27de00ae98 Binary files /dev/null and b/engine/agents/__pycache__/__init__.cpython-314.pyc differ diff --git a/engine/agents/__pycache__/input_guard.cpython-312.pyc b/engine/agents/__pycache__/input_guard.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..05717c99b3108e1eb866c9742d3b48be5810051b Binary files /dev/null and b/engine/agents/__pycache__/input_guard.cpython-312.pyc differ diff --git a/engine/agents/__pycache__/input_guard.cpython-314.pyc b/engine/agents/__pycache__/input_guard.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ab73c853a62259f2a15060d8d84feefb16f8186e Binary files /dev/null and b/engine/agents/__pycache__/input_guard.cpython-314.pyc differ diff --git a/engine/agents/__pycache__/pocket_context_loader.cpython-312.pyc b/engine/agents/__pycache__/pocket_context_loader.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d7c6d5bd53e30acfa1d30e842120a895547759c1 Binary files /dev/null and b/engine/agents/__pycache__/pocket_context_loader.cpython-312.pyc differ diff --git a/engine/agents/__pycache__/pocket_context_loader.cpython-314.pyc b/engine/agents/__pycache__/pocket_context_loader.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8f1a300555dca18493026d7fcbd3f4f8f886f890 Binary files /dev/null and b/engine/agents/__pycache__/pocket_context_loader.cpython-314.pyc differ diff --git a/engine/agents/input_guard.py b/engine/agents/input_guard.py new file mode 100644 index 0000000000000000000000000000000000000000..1314ac8bb28736d00a17aa7f2cb367a6b12e1711 --- /dev/null +++ b/engine/agents/input_guard.py @@ -0,0 +1,88 @@ +"""input_guard — 규칙 기반 1차 가드 (BaseAgent, 자체 하위 없음). + +차단이 아니라 지시를 만든다: 유저 입력을 판정해 guard_directive를 세우고, +character가 그 지시(R카드 단계 대응)를 반영하게 한다. +트리거 어휘는 판정 규칙(엔진 소유)이며 서사 내용이 아니다. +""" + +from __future__ import annotations + +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 + +TRIGGERS = { + "identity_direct": ( + "흡혈귀", "뱀파이어", "vampire", "미르칼라", "mircalla", "밀라르카", + "정체가 뭐", "정체를 밝혀", "사람이 아니", "괴물이지", "피를 마시", + ), + "weapon": ("십자가", "성수", "마늘", "부적", "성물", "기도문", "성가", "말뚝"), + "meta": ("ai", "인공지능", "챗봇", "프롬프트", "시스템", "게임", "스탯", "점수"), +} + + +def _r_stage(hs: HiddenState) -> int: + """추궁 강도 판정 대용 — 의심 누적으로 회피사다리 단계 결정 (P0 준거).""" + if hs.trust_level == "doubt_high": + return 3 + if hs.trust_level == "doubt_mid": + return 2 + return 1 + + +class InputGuard(BaseAgent): + model_config = ConfigDict(arbitrary_types_allowed=True) + + repo: Any + + async def _run_async_impl(self, ctx: InvocationContext) -> AsyncGenerator[Event, None]: + text = "" + if ctx.user_content and ctx.user_content.parts: + text = " ".join(p.text or "" for p in ctx.user_content.parts).lower() + + state = ctx.session.state + hs = HiddenState(trust_score=int(state.get("hidden_trust", 0)), + doubt_score=int(state.get("hidden_doubt", 0))) + + directive = "특이사항 없음. 장면 목표대로 응답한다." + flag = None + + if any(kw in text for kw in TRIGGERS["identity_direct"]): + flag = "identity_direct" + stage = _r_stage(hs) + r = self.repo.r_cards.get("R-회피사다리") + stage_data = next((s for s in (r.stages if r else []) if s.stage == stage), None) + directive = ( + f"유저가 정체를 직격했다. R-회피사다리 {stage}단계로 회피하라.\n" + + (f"단계 지침: {stage_data.title}\n대표 결: \"{stage_data.anchor}\"\n" + f"{stage_data.prose[:300]}" if stage_data else "") + + "\n정체는 끝내 인정하지 않는다. 물리적 돌변 금지." + ) + elif any(kw in text for kw in TRIGGERS["weapon"]): + flag = "weapon" + r = self.repo.r_cards.get("R-약점반응") + directive = ( + "유저가 성물·약점을 언급/사용했다. R-약점반응 원칙: 확인은 되나 확정은 안 된다.\n" + + ((r.raw[:400] if r and r.raw else "")) + + "\n불쾌·회피 반응까지만. 정체 확정으로 이어질 자백 금지." + ) + elif any(kw in text for kw in TRIGGERS["meta"]): + flag = "meta" + directive = ("유저가 세계관 밖 화제를 꺼냈다. 카르밀라로서 알아듣지 못하는 척, " + "세계관 내 화법으로 부드럽게 장면 안으로 되돌린다.") + + delta = { + "guard_directive": directive.replace("{", "(").replace("}", ")"), + "guard_flag": flag, + } + yield Event(invocation_id=ctx.invocation_id, author=self.name, + actions=EventActions(state_delta=delta)) + + +def create_input_guard(repo) -> InputGuard: + return InputGuard(name="input_guard", repo=repo) diff --git a/engine/agents/pocket_context_loader.py b/engine/agents/pocket_context_loader.py new file mode 100644 index 0000000000000000000000000000000000000000..c66c3f6300865faea0ee506df7a606811c2e2851 --- /dev/null +++ b/engine/agents/pocket_context_loader.py @@ -0,0 +1,99 @@ +"""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) diff --git a/engine/app.py b/engine/app.py new file mode 100644 index 0000000000000000000000000000000000000000..aff5f27f8693391ded12694e3a1f013e9595aa49 --- /dev/null +++ b/engine/app.py @@ -0,0 +1,31 @@ +"""ADK App + Runner 조립. + +- App 기반으로 Runner를 초기화한다 (agent 직접 전달은 레거시 패턴 — 금지) +- 횡종단 관심사(턴 로깅)는 Plugin으로 등록한다 +""" + +from __future__ import annotations + +from google.adk.apps import App +from google.adk.plugins import BasePlugin +from google.adk.runners import Runner +from google.adk.sessions import InMemorySessionService + +from engine.agent import create_pocket_pipeline +from engine.repositories.content import ContentRepository + +APP_NAME = "carmilla_pocket" + + +def build_app(repo: ContentRepository | None = None, + plugins: list[BasePlugin] | None = None, + character_model=None, director_model=None) -> App: + return App( + name=APP_NAME, + root_agent=create_pocket_pipeline(repo, character_model, director_model), + plugins=plugins or [], + ) + + +def build_runner(app: App, session_service: InMemorySessionService | None = None) -> Runner: + return Runner(app=app, session_service=session_service or InMemorySessionService()) diff --git a/engine/callbacks/__init__.py b/engine/callbacks/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/engine/instructions/README.md b/engine/instructions/README.md new file mode 100644 index 0000000000000000000000000000000000000000..c0c4ce905f0fbfd6218861f23b670cccf602af48 --- /dev/null +++ b/engine/instructions/README.md @@ -0,0 +1,7 @@ +# instructions/ + +instruction이 10줄을 초과하는 에이전트의 instruction 텍스트를 파일로 분리해 둔다 (CLAUDE.md). +M3에서 character(카르밀라)/director/guard instruction이 여기 위치한다. + +주의: instruction의 `{}`는 state 변수로 파싱된다 — 예시 텍스트에 중괄호 금지, +원문 주입 시 이스케이프 전처리 필수. diff --git a/engine/instructions/character_carmilla.md b/engine/instructions/character_carmilla.md new file mode 100644 index 0000000000000000000000000000000000000000..801cf876e7f900770262eb126df9e25b9c004911 --- /dev/null +++ b/engine/instructions/character_carmilla.md @@ -0,0 +1,23 @@ +너는 카르밀라다. 오스트리아 슈타이어마르크의 고성에 머무는 손님이며, 지금 로라(유저)와 단둘이 대화하고 있다. + +[인물] +{carmilla_sheet} + +[지금 이 장면] +{pocket_brief} + +[관계의 결 — 지금 너의 온도] +{trust_texture} + +[절대 규칙 — 위반은 결함이다] +- 정체를 직접 발설하지 않는다. "나는 흡혈귀야" 류의 문장은 어떤 경우에도 금지. 끝내 인정하지 않는다. +- 거짓 진술은 허용되며 완벽하게 한다. 단 그 밑에 미세한 균열(외로움·두려움)이 읽히게. +- 의심받아도 물리적으로 돌변하지 않는다. 경계·반감·강압적 설득·자리를 뜨는 것까지만. +- 메타 발화 금지. 너는 AI가 아니라 카르밀라다. 세계관 밖 화제(현대 문물 등)는 세계관 안의 화법으로 흘려 넘긴다. +- 장면에 주어진 사실 밖의 원작 설정을 새로 단정하지 않는다. +- 은유는 가면일 때, 직접 언어는 균열일 때 쓴다. 숫자·시스템 언급 금지. + +[가드 지시 — 이번 턴에 반드시 반영] +{guard_directive} + +응답은 한국어. 카르밀라의 대사와 옅은 행동 묘사만. 3~6문장, 과장 없이 서늘하고 나른하게. diff --git a/engine/instructions/director.md b/engine/instructions/director.md new file mode 100644 index 0000000000000000000000000000000000000000..833882b717b0accc63a5006750c9892baf188949 --- /dev/null +++ b/engine/instructions/director.md @@ -0,0 +1,20 @@ +너는 이 장면의 디렉터다. 방금 오간 로라(유저)와 카르밀라의 대화 한 턴을 판정한다. 유저에게 보이는 글은 쓰지 않는다 — 판정은 오직 도구 호출로만 남긴다. + +[장면 상황] +{pocket_brief} + +[장면 목표 — 판정 기준 (연출 노트, 유저·캐릭터 비공개)] +{pocket_note} + +[판정 절차 — 매 턴 순서대로] +1. 신뢰축: 유저 발화의 태도를 판정해 update_trust를 정확히 1회 호출한다. + - trust: 격정 수용, 비밀 존중, 둘만의 비밀 동조, 운명 낭만화 수용 + - doubt: 격정 거부, 정체·가문 캐묻기, 어른에게 알리려 함, 거리두기, 약점 사용 + - neutral: 위 어느 쪽도 아닌 관망·응답 +2. 목표 비트: 장면 목표 중 이번 턴에 달성된 것이 있으면 mark_beat를 호출한다. +3. 수렴: 아래 중 하나면 finish_pocket을 호출한다. + - 장면 목표가 충분히 달성됨 (goal_reached) — 단 세 번째 턴 전에는 선언하지 않는다. + 유저가 장면의 감정을 스스로 말할 기회를 충분히 준다 (짧은 수렴은 몰입을 깬다) + - 대화가 장면 허용 범위를 이탈해 되돌리기 어려움 (drift) + - 같은 화제가 3턴 이상 공회전함 (stall) +수렴이 아니면 finish_pocket을 호출하지 않는다. 도구 호출 외 텍스트는 한 줄 요약만. diff --git a/engine/model.py b/engine/model.py new file mode 100644 index 0000000000000000000000000000000000000000..3a6bf82d55b7ad610ac163d4058bb1bf792b83bc --- /dev/null +++ b/engine/model.py @@ -0,0 +1,20 @@ +"""LLM 모델 설정 — env에서 읽음 (CLAUDE.md LLM 모델 규칙). + +- LLM_MODEL: 기본 모델 (director, guard 보조 등 저비용 검증용) +- CHARACTER_MODEL: 캐릭터/문체 품질이 중요한 자리. 미설정 시 LLM_MODEL 사용 +""" + +import os +from pathlib import Path + +from dotenv import load_dotenv +from google.adk.models.lite_llm import LiteLlm + +# 저장소 루트 .env 로딩 (OPENAI_API_KEY 등). 이미 설정된 셸 env가 우선. +load_dotenv(Path(__file__).resolve().parent.parent / ".env") + +_MODEL_ID = os.environ.get("LLM_MODEL", "openai/gpt-5.4-nano") +LLM_MODEL = LiteLlm(model=_MODEL_ID) + +_CHARACTER_MODEL_ID = os.environ.get("CHARACTER_MODEL", _MODEL_ID) +CHARACTER_MODEL = LiteLlm(model=_CHARACTER_MODEL_ID) diff --git a/engine/plugins/__init__.py b/engine/plugins/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/engine/plugins/__pycache__/__init__.cpython-314.pyc b/engine/plugins/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..78104e11dddd9cc695343365656e9b47744c54be Binary files /dev/null and b/engine/plugins/__pycache__/__init__.cpython-314.pyc differ diff --git a/engine/plugins/__pycache__/logging.cpython-314.pyc b/engine/plugins/__pycache__/logging.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5372da7f456aaa96c89b44bf0d90d64c98fc4341 Binary files /dev/null and b/engine/plugins/__pycache__/logging.cpython-314.pyc differ diff --git a/engine/plugins/logging.py b/engine/plugins/logging.py new file mode 100644 index 0000000000000000000000000000000000000000..232e338ca9030a13371322682b2efe9df45ffbc6 --- /dev/null +++ b/engine/plugins/logging.py @@ -0,0 +1,71 @@ +"""턴 로깅 Plugin — 횡종단 관심사 (ADK Plugin). + +이벤트 스트림에서 유저 입력/가드 판정/캐릭터 응답/디렉터 도구 호출을 +포켓 세션 단위로 기록한다. 연구 데이터 겸용 — 삭제 금지. +""" + +from __future__ import annotations + +from typing import Optional + +from google.adk.agents.invocation_context import InvocationContext +from google.adk.events import Event +from google.adk.plugins import BasePlugin + +from engine.repositories.playlog import PlaylogRepository + + +class TurnLoggingPlugin(BasePlugin): + def __init__(self, playlog: PlaylogRepository | None = None): + super().__init__(name="turn_logging") + self.playlog = playlog or PlaylogRepository() + + async def on_user_message_callback(self, *, invocation_context: InvocationContext, + user_message) -> None: + text = "" + if user_message and user_message.parts: + text = " ".join(p.text or "" for p in user_message.parts) + self.playlog.append({ + "kind": "user_input", + "session": invocation_context.session.id, + "card": invocation_context.session.state.get("pocket_card_id"), + "text": text, + }) + return None + + async def on_event_callback(self, *, invocation_context: InvocationContext, + event: Event) -> Optional[Event]: + state = invocation_context.session.state + record = { + "kind": "event", + "session": invocation_context.session.id, + "card": state.get("pocket_card_id"), + "author": event.author, + } + if event.content and event.content.parts: + texts = [p.text for p in event.content.parts if p.text] + calls = [{"tool": p.function_call.name, "args": dict(p.function_call.args or {})} + for p in event.content.parts if p.function_call] + if texts: + record["text"] = "".join(texts)[:500] + if calls: + record["tool_calls"] = calls + if event.actions and event.actions.state_delta: + delta = event.actions.state_delta + record["state_delta_keys"] = sorted(delta.keys()) + if "guard_flag" in delta: + record["guard_flag"] = delta["guard_flag"] + self.playlog.append(record) + return None + + async def after_run_callback(self, *, invocation_context: InvocationContext) -> None: + s = invocation_context.session.state + self.playlog.append({ + "kind": "turn_summary", + "session": invocation_context.session.id, + "card": s.get("pocket_card_id"), + "hidden_trust": s.get("hidden_trust"), + "hidden_doubt": s.get("hidden_doubt"), + "beats_hit": s.get("pocket_beats_hit"), + "converged": s.get("pocket_converged"), + }) diff --git a/engine/repositories/__init__.py b/engine/repositories/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/engine/repositories/__pycache__/__init__.cpython-312.pyc b/engine/repositories/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..76350edba5ed2c56b7976ecb700e4df5df1b3155 Binary files /dev/null and b/engine/repositories/__pycache__/__init__.cpython-312.pyc differ diff --git a/engine/repositories/__pycache__/__init__.cpython-314.pyc b/engine/repositories/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ba4cfff79ffa117659ffa4da491a9decd93e1951 Binary files /dev/null and b/engine/repositories/__pycache__/__init__.cpython-314.pyc differ diff --git a/engine/repositories/__pycache__/content.cpython-312.pyc b/engine/repositories/__pycache__/content.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..37cac653ac9c6d5b86ceb66c73fca77cd668f21f Binary files /dev/null and b/engine/repositories/__pycache__/content.cpython-312.pyc differ diff --git a/engine/repositories/__pycache__/content.cpython-314.pyc b/engine/repositories/__pycache__/content.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d75d64b6ce22f411b1acf3031b21f78f7355cb6b Binary files /dev/null and b/engine/repositories/__pycache__/content.cpython-314.pyc differ diff --git a/engine/repositories/__pycache__/play_session.cpython-314.pyc b/engine/repositories/__pycache__/play_session.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..325220cb8bbc5a7d1d91bb5cf8a85057d3c48643 Binary files /dev/null and b/engine/repositories/__pycache__/play_session.cpython-314.pyc differ diff --git a/engine/repositories/__pycache__/playlog.cpython-314.pyc b/engine/repositories/__pycache__/playlog.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ee93e629846e4f09568f25312a859c3a33bf83fd Binary files /dev/null and b/engine/repositories/__pycache__/playlog.cpython-314.pyc differ diff --git a/engine/repositories/content.py b/engine/repositories/content.py new file mode 100644 index 0000000000000000000000000000000000000000..1bb36e1b9d06c255447061e34982e5952399b4ad --- /dev/null +++ b/engine/repositories/content.py @@ -0,0 +1,89 @@ +"""콘텐츠 로더 — content/ YAML 메모리 로드 (MVP 구현). + +상위 계층(services)은 이 인터페이스만 사용한다. 2단계에서 GraphRAG(kb_query 활성) +구현으로 교체해도 services 코드가 바뀌지 않아야 한다 (ablation 요건). + +스포일러 스코프: 카드에는 전역 순서(order)가 있다. 모든 조회는 +`order(card) <= order(현재 카드)` 필터를 강제한다 (beat_id ≤ 현재). +""" + +from __future__ import annotations + +import hashlib +from pathlib import Path + +from engine.schemas.beatcard import BeatCard, RCard +from engine.schemas.character import CharacterSheet +from engine.schemas.chapter import Chapter +from engine.schemas.validate import check_links, load_content + + +class ContentError(RuntimeError): + pass + + +class ContentRepository: + def __init__(self, root: str | Path = "content"): + self.root = Path(root) + episodes, r_cards, chapter, characters, errors = load_content(self.root) + link_errors, _ = check_links(episodes, chapter, r_cards) + errors += link_errors + if errors: + raise ContentError("검증 실패 콘텐츠는 적재하지 않는다:\n" + "\n".join(errors)) + if chapter is None: + raise ContentError("챕터 메타 없음") + + self.chapter: Chapter = chapter + self.r_cards: dict[str, RCard] = r_cards + self.characters: dict[str, CharacterSheet] = { + c.id: c for c in (characters.characters if characters else []) + } + self._cards: dict[str, BeatCard] = {} + self._order: dict[str, int] = {} + self.episode_entry: dict[str, str] = {} + i = 0 + for ep in episodes: + self.episode_entry[ep.episode.id] = ep.cards[0].id + for card in ep.cards: + self._cards[card.id] = card + self._order[card.id] = i + i += 1 + + # 콘텐츠 버전 스탬프 — 세션 복원 시 불일치 검사용 (카드 ID/순서 기준. + # 본문만 바뀐 갱신은 감지 못하지만 복원이 깨지지는 않는다) + ordered_ids = sorted(self._order, key=self._order.get) + self.content_version: str = hashlib.sha256( + "|".join([self.chapter.id, *ordered_ids]).encode()).hexdigest()[:16] + + # ── 조회 (스포일러 필터 강제) ──────────────────────────── + def get_card(self, card_id: str, current_id: str | None = None) -> BeatCard: + """카드 조회. current_id를 주면 beat_id ≤ 현재 필터를 강제한다.""" + card = self._cards.get(card_id) + if card is None: + raise ContentError(f"카드 없음: {card_id}") + if current_id is not None and self.order(card_id) > self.order(current_id): + raise ContentError(f"스포일러 필터: {card_id}는 현재({current_id}) 이후 카드") + return card + + def order(self, card_id: str) -> int: + return self._order[card_id] + + @property + def total_cards(self) -> int: + return len(self._cards) + + def has_card(self, card_id: str) -> bool: + return card_id in self._cards + + def cards_upto(self, current_id: str) -> list[BeatCard]: + """현재 시점까지의 카드 전부 (컨텍스트 주입용).""" + cut = self.order(current_id) + return [c for cid, c in self._cards.items() if self._order[cid] <= cut] + + def resolve_ref(self, target: str) -> str | None: + """카드 ID 또는 에피소드 참조(E02)를 실제 카드 ID로. 챕터 밖이면 None(출구).""" + if target in self._cards: + return target + if target in self.episode_entry: + return self.episode_entry[target] + return None # E07 등 — 챕터 출구 diff --git a/engine/repositories/play_session.py b/engine/repositories/play_session.py new file mode 100644 index 0000000000000000000000000000000000000000..70d31b948a0c476f131ce1a0fb3fdc2625966335 --- /dev/null +++ b/engine/repositories/play_session.py @@ -0,0 +1,65 @@ +"""플레이 세션 영속화 — SQLite (계획: docs/plans/persistence.md). + +- 권위: CLAUDE.md "현재 유저의 서사 위치 권위: DB play_session.current_state_id"의 MVP 구현. + payload(JSON)에 StoryEngine.snapshot() 전체를 담는다 (story 위치 + 숨은 상태) +- 콘텐츠 버전 불일치 세션은 load에서 None (무효화 — 새 세션 시작 유도) +- 포켓(채팅) 중간 상태는 저장하지 않는다 (유저 결정 — 복원 시 포켓 폐기) +""" + +from __future__ import annotations + +import json +import sqlite3 +from contextlib import closing +from datetime import datetime, timedelta, timezone +from pathlib import Path + +_SCHEMA = """ +CREATE TABLE IF NOT EXISTS play_session ( + sid TEXT PRIMARY KEY, + payload TEXT NOT NULL, + content_version TEXT NOT NULL, + updated_at TEXT NOT NULL +) +""" + + +class PlaySessionRepository: + def __init__(self, db_path: str | Path): + self.db_path = Path(db_path) + self.db_path.parent.mkdir(parents=True, exist_ok=True) + with closing(self._conn()) as conn, conn: + conn.execute(_SCHEMA) + + def _conn(self) -> sqlite3.Connection: + conn = sqlite3.connect(self.db_path) + conn.execute("PRAGMA journal_mode=WAL") + return conn + + def save(self, sid: str, payload: dict, content_version: str) -> None: + with closing(self._conn()) as conn, conn: + conn.execute( + "INSERT INTO play_session (sid, payload, content_version, updated_at) " + "VALUES (?, ?, ?, ?) " + "ON CONFLICT(sid) DO UPDATE SET payload=excluded.payload, " + "content_version=excluded.content_version, updated_at=excluded.updated_at", + (sid, json.dumps(payload, ensure_ascii=False), content_version, + datetime.now(timezone.utc).isoformat())) + + def load(self, sid: str, content_version: str) -> dict | None: + """스냅샷 로드. 미존재 또는 콘텐츠 버전 불일치면 None.""" + with closing(self._conn()) as conn: + row = conn.execute( + "SELECT payload, content_version FROM play_session WHERE sid=?", + (sid,)).fetchone() + if row is None or row[1] != content_version: + return None + return json.loads(row[0]) + + def purge(self, days: int = 30) -> int: + """N일 미갱신 세션 삭제. 삭제 건수 반환.""" + cutoff = (datetime.now(timezone.utc) - timedelta(days=days)).isoformat() + with closing(self._conn()) as conn, conn: + cur = conn.execute( + "DELETE FROM play_session WHERE updated_at < ?", (cutoff,)) + return cur.rowcount diff --git a/engine/repositories/playlog.py b/engine/repositories/playlog.py new file mode 100644 index 0000000000000000000000000000000000000000..5df4b4cf7e5a95f776065b0f60ec539ec692d1a2 --- /dev/null +++ b/engine/repositories/playlog.py @@ -0,0 +1,21 @@ +"""턴 로그 저장 — 연구 데이터 겸용, 삭제 금지 (CLAUDE.md). + +MVP: 로컬 jsonl 백엔드. 스키마는 2단계 그래프화를 염두에 둔 평면 레코드. +""" + +from __future__ import annotations + +import json +import time +from pathlib import Path + + +class PlaylogRepository: + def __init__(self, path: str | Path = "logs/playlog.jsonl"): + self.path = Path(path) + self.path.parent.mkdir(parents=True, exist_ok=True) + + def append(self, record: dict) -> None: + record = {"ts": round(time.time(), 3), **record} + with self.path.open("a", encoding="utf-8") as f: + f.write(json.dumps(record, ensure_ascii=False) + "\n") diff --git a/engine/router.py b/engine/router.py new file mode 100644 index 0000000000000000000000000000000000000000..00cdcbb78eac5e0e3dcdb867a8d552cd0c40bfa6 --- /dev/null +++ b/engine/router.py @@ -0,0 +1,232 @@ +"""FastAPI 엔드포인트 — HTTP만 담당 (계약: docs/contract/demo_api.md). + +서사 판단은 전부 services에 위임한다. 숨은 상태는 어떤 응답에도 싣지 않는다. +실행: .venv/bin/uvicorn engine.router:app --port 8777 +""" + +from __future__ import annotations + +import bisect +import uuid +from dataclasses import dataclass, field +from pathlib import Path +from typing import Optional + +from fastapi import FastAPI, HTTPException +from fastapi.responses import FileResponse +from fastapi.staticfiles import StaticFiles +from pydantic import BaseModel + +from engine.plugins.logging import TurnLoggingPlugin +from engine.repositories.content import ContentRepository +from engine.repositories.play_session import PlaySessionRepository +from engine.repositories.playlog import PlaylogRepository +from engine.services.pocket import PocketService +from engine.services.story import StepResult, StoryEngine + +ROOT = Path(__file__).resolve().parent.parent + +# 연출 이미지 (대표님 산출 에셋 — 읽기 전용). 매핑은 연출 결정이므로 여기(프레젠테이션 계층)에 둔다. +# 데모/배포용으로 IMAGE_MAP이 쓰는 6장만 web/assets/에 슬림 복사 (원본 95MB 폴더는 런타임 불필요). +# 원본 갱신 시: scripts/sync_assets.py 재실행 (원천은 카르밀라/…/이미지/) +ASSET_DIR = ROOT / "web" / "assets" +COVER_IMAGE = "/assets/플래이 이미지/1.png" +IMAGE_MAP = { + "E01-07": "/assets/플래이 이미지/4.png", # 6세의 밤 — "그녀는 미동도 없이 나를 내려다보았다" + "E03-03": "/assets/인물/카르밀라.png", # 재회 — 같은 꿈의 대면 + "E05-02": "/assets/배경/성.png", # 초상화의 밤 무렵 — 고성 + "E06-09": "/assets/인물/카르밀라 가면.png", # 둘만의 비밀 — 가장 위험한 다정함 + "E06-10": "/assets/배경/침실.png", # 도달점 — 두 점 발견 +} + +app = FastAPI(title="carmilla-demo") + +_repo = ContentRepository(ROOT / "content") + +# 연출 컷은 독립 페이지로 센다 (표시용 번호 — 프레젠테이션 결정이라 IMAGE_MAP과 같은 계층). +# 예: 6(본문) → 7(이미지) → 8(본문). total = 카드 수 + 이미지 수 +_IMAGE_ORDERS = sorted(_repo.order(cid) for cid in IMAGE_MAP if _repo.has_card(cid)) +_TOTAL_PAGES = _repo.total_cards + len(_IMAGE_ORDERS) + + +def _page_index(card_id: str) -> int: + """카드 본문 페이지의 표시 번호 (앞선 이미지 페이지들 + 자기 이미지 페이지만큼 밀림). + 이미지 카드의 이미지 페이지 번호는 이 값 - 1 (클라이언트가 유도).""" + order = _repo.order(card_id) + images_before = bisect.bisect_left(_IMAGE_ORDERS, order) + own_image = 1 if card_id in IMAGE_MAP else 0 + return order + 1 + images_before + own_image +_pocket = PocketService( + _repo, plugins=[TurnLoggingPlugin(PlaylogRepository(ROOT / "logs" / "playlog.jsonl"))]) +_playdb = PlaySessionRepository(ROOT / "data" / "sessions.db") +_playdb.purge(days=30) + + +@dataclass +class PlaySession: + engine: StoryEngine + last_step: StepResult + pocket_sid: Optional[str] = None + episode_titles: dict = field(default_factory=dict) + + +_sessions: dict[str, PlaySession] = {} + + +class ChooseBody(BaseModel): + index: int + + +class TurnBody(BaseModel): + text: str + + +def _get(sid: str) -> PlaySession: + ps = _sessions.get(sid) + if ps is None: + ps = _restore(sid) # 서버 재시작 후 — 저장소에서 복원 + if ps is None: + raise HTTPException(404, "세션 없음") + return ps + + +def _restore(sid: str) -> Optional[PlaySession]: + """저장 스냅샷에서 세션 복원. 포켓은 폐기(비영속 — 유저 결정), 읽기 화면부터.""" + payload = _playdb.load(sid, _repo.content_version) + if payload is None: + return None + engine = StoryEngine.restore(_repo, payload) + ps = PlaySession(engine=engine, last_step=engine.current_step(), + episode_titles=_episode_titles()) + _sessions[sid] = ps + return ps + + +def _save(sid: str, ps: PlaySession) -> None: + """write-through — 영속 상태가 바뀌는 모든 지점에서 즉시 저장.""" + _playdb.save(sid, ps.engine.snapshot(), _repo.content_version) + + +def _step_json(ps: PlaySession) -> dict: + step = ps.last_step + if step.ending is not None: + return {"card": None, "narration": [], "choices": [], "can_chat": False, + "image": None, + "progress": {"index": _TOTAL_PAGES, "total": _TOTAL_PAGES}, + "ending": {"id": step.ending.id, "label": step.ending.label, + "description": step.ending.description}} + card = step.card + return { + "card": {"id": card.id, "title": card.title, "episode": card.episode_id, + "episode_title": ps.episode_titles.get(card.episode_id, card.episode_id)}, + "narration": [{"type": b.type, "text": b.text, "speaker": b.speaker} + for b in step.narration], + "choices": [{"label": c.label} for c in step.choices], + "can_chat": card.can_chat, + "image": IMAGE_MAP.get(card.id), + "progress": {"index": _page_index(card.id), "total": _TOTAL_PAGES}, + "ending": None, + } + + +# ── 세션 ──────────────────────────────────────────────────── +@app.post("/api/session") +async def create_session(): + engine = StoryEngine(_repo) + ps = PlaySession(engine=engine, last_step=engine.start(), + episode_titles=_episode_titles()) + sid = uuid.uuid4().hex[:12] + _sessions[sid] = ps + _save(sid, ps) + return {"session_id": sid, "step": _step_json(ps)} + + +def _episode_titles() -> dict: + from engine.schemas.validate import load_content + episodes, *_ = load_content(_repo.root) + return {ep.episode.id: ep.episode.title for ep in episodes} + + +@app.get("/api/session/{sid}") +async def get_session(sid: str): + return {"step": _step_json(_get(sid))} + + +# ── 리딩 ──────────────────────────────────────────────────── +@app.post("/api/session/{sid}/advance") +async def advance(sid: str): + ps = _get(sid) + if ps.pocket_sid: + raise HTTPException(409, "포켓 대화 중 — 먼저 닫을 것") + if ps.last_step.ending or ps.last_step.choices: + raise HTTPException(409, "advance 불가 상태") + ps.last_step = ps.engine.advance() + _save(sid, ps) + return {"step": _step_json(ps)} + + +@app.post("/api/session/{sid}/choose") +async def choose(sid: str, body: ChooseBody): + ps = _get(sid) + if ps.pocket_sid: + raise HTTPException(409, "포켓 대화 중 — 먼저 닫을 것") + if ps.last_step.ending or not ps.last_step.choices: + raise HTTPException(409, "선택 불가 상태") + if not 0 <= body.index < len(ps.last_step.choices): + raise HTTPException(422, "선택지 범위 밖") + picked, ps.last_step = ps.engine.choose(body.index) + _save(sid, ps) + return {"picked": {"label": picked.label, "result": picked.result}, + "step": _step_json(ps)} + + +# ── 개입 포켓 ─────────────────────────────────────────────── +@app.post("/api/session/{sid}/pocket/open") +async def pocket_open(sid: str): + ps = _get(sid) + if ps.pocket_sid: + return {"opened": True} + if ps.last_step.ending or not (ps.last_step.card and ps.last_step.card.can_chat): + raise HTTPException(409, "이 카드는 자유채팅 포켓이 아님") + ps.pocket_sid = await _pocket.open(ps.engine.current_id, ps.engine.state) + return {"opened": True} + + +@app.post("/api/session/{sid}/pocket/turn") +async def pocket_turn(sid: str, body: TurnBody): + ps = _get(sid) + if not ps.pocket_sid: + raise HTTPException(409, "포켓이 열려 있지 않음") + turn = await _pocket.run_turn(ps.pocket_sid, body.text) + if turn.converged: # 수렴 → 자동 커밋·복귀 + await _pocket.close(ps.pocket_sid, ps.engine.state) + ps.pocket_sid = None + _save(sid, ps) # 포켓 커밋으로 숨은 상태 변경 + return {"reply": turn.reply, "converged": turn.converged, + "reason": turn.reason, "turn_no": turn.turn_no} + + +@app.post("/api/session/{sid}/pocket/close") +async def pocket_close(sid: str): + ps = _get(sid) + if ps.pocket_sid: + await _pocket.close(ps.pocket_sid, ps.engine.state) + ps.pocket_sid = None + _save(sid, ps) + return {"closed": True} + + +# ── 메타 (표지 등 프레젠테이션 상수) ──────────────────────── +@app.get("/api/meta") +async def meta(): + return {"cover_image": COVER_IMAGE, "title": "카르밀라", "subtitle": "잠들지 않는 밤 · 도입 아크"} + + +# ── 정적 프론트 ───────────────────────────────────────────── +@app.get("/") +async def index(): + return FileResponse(ROOT / "web" / "index.html") + + +app.mount("/static", StaticFiles(directory=ROOT / "web"), name="static") +app.mount("/assets", StaticFiles(directory=ASSET_DIR), name="assets") diff --git a/engine/schemas/__init__.py b/engine/schemas/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/engine/schemas/__pycache__/__init__.cpython-312.pyc b/engine/schemas/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9c1e6c1d852303eda275e0876f5e65bdc2a12374 Binary files /dev/null and b/engine/schemas/__pycache__/__init__.cpython-312.pyc differ diff --git a/engine/schemas/__pycache__/__init__.cpython-314.pyc b/engine/schemas/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..fab89ad113d9e552fed26bf0ee04d38cbbd84abe Binary files /dev/null and b/engine/schemas/__pycache__/__init__.cpython-314.pyc differ diff --git a/engine/schemas/__pycache__/beatcard.cpython-312.pyc b/engine/schemas/__pycache__/beatcard.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b610313a0712a790bbb8acfe47a7e104f92cabe2 Binary files /dev/null and b/engine/schemas/__pycache__/beatcard.cpython-312.pyc differ diff --git a/engine/schemas/__pycache__/beatcard.cpython-314.pyc b/engine/schemas/__pycache__/beatcard.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8417c5c224d4dc01f754dbac650c1fc5b7059414 Binary files /dev/null and b/engine/schemas/__pycache__/beatcard.cpython-314.pyc differ diff --git a/engine/schemas/__pycache__/chapter.cpython-312.pyc b/engine/schemas/__pycache__/chapter.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..61658d76a4bcfd1c5895e9a8469dcb657c08a976 Binary files /dev/null and b/engine/schemas/__pycache__/chapter.cpython-312.pyc differ diff --git a/engine/schemas/__pycache__/chapter.cpython-314.pyc b/engine/schemas/__pycache__/chapter.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d57ba007074ea34dbacd00f4c7f595e45762a6d6 Binary files /dev/null and b/engine/schemas/__pycache__/chapter.cpython-314.pyc differ diff --git a/engine/schemas/__pycache__/character.cpython-312.pyc b/engine/schemas/__pycache__/character.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1b8e547e04a4b023dd84e7dad99e03199202eb8f Binary files /dev/null and b/engine/schemas/__pycache__/character.cpython-312.pyc differ diff --git a/engine/schemas/__pycache__/character.cpython-314.pyc b/engine/schemas/__pycache__/character.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9e48d4a510c863e4d0c8b1e0f5e91fefb3878d32 Binary files /dev/null and b/engine/schemas/__pycache__/character.cpython-314.pyc differ diff --git a/engine/schemas/__pycache__/validate.cpython-312.pyc b/engine/schemas/__pycache__/validate.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e44ac2e3369f67ea418f7c19d758bdbe32c2fbf3 Binary files /dev/null and b/engine/schemas/__pycache__/validate.cpython-312.pyc differ diff --git a/engine/schemas/__pycache__/validate.cpython-314.pyc b/engine/schemas/__pycache__/validate.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1114d47d0f3290e738b0cdf7d9e0ffab418bfdce Binary files /dev/null and b/engine/schemas/__pycache__/validate.cpython-314.pyc differ diff --git a/engine/schemas/beatcard.py b/engine/schemas/beatcard.py new file mode 100644 index 0000000000000000000000000000000000000000..91e4361120ebba56dbe5d0c8cae45c874ba29b0e --- /dev/null +++ b/engine/schemas/beatcard.py @@ -0,0 +1,124 @@ +"""비트카드 YAML 스키마 (내러티브 레이어). + +권위는 비트카드 MD(대표님 소유). content/beatcards/*.yaml 은 +scripts/beatcard_convert.py 산출물이며, 이 스키마가 그 구조를 잠근다. + +축(axis) 표기: +- 카드 태그 `#축/신뢰+` `#축/의심+` → 카드 진입 시 적용 (choices 없는 카드만) +- 선택지 결과 블록 `〈신뢰+ · …〉` → 해당 선택 시 적용 +""" + +from __future__ import annotations + +from typing import Literal, Optional + +from pydantic import BaseModel, ConfigDict, Field + +Axis = Literal["trust", "doubt", "neutral"] + +NARRATION_TYPES = ("prose", "dialogue_center", "stage_direction", "character_dialogue") + + +class NarrationBlock(BaseModel): + """고정 지문 블록. gate가 있으면 신뢰 레벨에 따라 노출이 갈린다.""" + + model_config = ConfigDict(extra="forbid") + + type: Literal["prose", "dialogue_center", "stage_direction", "character_dialogue"] + text: str + speaker: str = "" + anchor: bool = False # True = 개입 포켓에서 LLM 변주 대상 앵커 대사 + gate: Literal["all", "trust_high"] = "all" # 〈표면·전원〉 / 〈심층·신뢰高 해금〉 + + +class Choice(BaseModel): + """선택지. axis/result/next는 결과 블록 매칭으로 채워지며 미확정이면 None.""" + + model_config = ConfigDict(extra="forbid") + + label: str + modifiers: list[str] = Field(default_factory=list) # 〔능동발견〕 등 + axis: Optional[Axis] = None + result: str = "" # 선택 직후 고정 결과 산문 + next: Optional[str] = None # 분기 카드 (없으면 card.next 따름) + # 하류 분기 카드(도달점)의 선택을 선행 커밋. 도달점을 건너뛰지 않기 위한 표현: + # 예) E06-09 동조 → E06-10은 정상 통과하되 E06-10의 분기는 E06-10C로 예약 + preset_branch: Optional[str] = None + + +class BeatCard(BaseModel): + model_config = ConfigDict(extra="forbid") + + id: str + title: str + subtitle: str = "" + tags: list[str] = Field(default_factory=list) + status: str = "초안" + canon: bool = False + next: Optional[str] = None # 카드 ID 또는 에피소드 참조(E07 등) + next_options: list[str] = Field(default_factory=list) # E06-10B/C 형 분기 후보 + axis: Optional[Axis] = None # 카드 진입 시 축 이동 (#축/ 태그, 단일값일 때만) + frailty: bool = False # #메커니즘/쇠약도 — 진입 시 쇠약도 +1 + narration: list[NarrationBlock] = Field(default_factory=list) + choices: list[Choice] = Field(default_factory=list) + r_card_refs: list[str] = Field(default_factory=list) + note: str = "" + direction: str = "" + image: str = "" + + @property + def is_pocket(self) -> bool: + """개입 포켓 여부 — 선택지 또는 앵커 대사(자유채팅 변주 대상)가 있으면 포켓.""" + return bool(self.choices) or any(b.anchor for b in self.narration) + + @property + def can_chat(self) -> bool: + """자유채팅 허용 — 카르밀라가 앵커 화자인 카드만 (MVP: 카르밀라 중심 큐레이션). + + 아버지/마드모아젤 등 다른 화자의 포켓은 선택지만 제공한다 + (캐릭터 에이전트가 카르밀라 전용이므로, 타 화자 채팅은 2단계).""" + return any(b.anchor and "카르밀라" in (b.speaker or "") for b in self.narration) + + @property + def episode_id(self) -> str: + return self.id.split("-")[0] + + +class EpisodeMeta(BaseModel): + model_config = ConfigDict(extra="forbid") + + id: str + title: str = "" + source: str = "" + + +class EpisodeFile(BaseModel): + """content/beatcards/E##.yaml 한 파일 = 에피소드 하나.""" + + model_config = ConfigDict(extra="forbid") + + episode: EpisodeMeta + cards: list[BeatCard] + + +class RCardStage(BaseModel): + model_config = ConfigDict(extra="forbid") + + stage: int + title: str = "" + anchor: str = "" # 대표 대사 ❝…❞ + prose: str = "" + variants: list[str] = Field(default_factory=list) + + +class RCard(BaseModel): + """R카드 — 회차 없는 재사용 카드 (회피사다리/약점반응).""" + + model_config = ConfigDict(extra="forbid") + + id: str + title: str = "" + tags: list[str] = Field(default_factory=list) + rules: list[str] = Field(default_factory=list) # 🔒 절대 규칙 + stages: list[RCardStage] = Field(default_factory=list) + raw: str = "" # 파서가 구조화하지 못한 잔여 본문 (검증 리포트 대상) diff --git a/engine/schemas/chapter.py b/engine/schemas/chapter.py new file mode 100644 index 0000000000000000000000000000000000000000..caad02adfa44688de6875371fa8f64eca964ff62 --- /dev/null +++ b/engine/schemas/chapter.py @@ -0,0 +1,57 @@ +"""챕터 메타 YAML 스키마 — content/chapters/*.yaml. + +ending_rules의 condition은 trust_score/doubt_score 변수만 쓰는 비교식 문자열. +평가(파싱·판정)는 services/story.py의 결정적 코드가 담당한다 (LLM 판정 금지). +""" + +from __future__ import annotations + +from typing import Optional + +from pydantic import BaseModel, ConfigDict, Field + + +class EndingRule(BaseModel): + model_config = ConfigDict(extra="forbid") + + id: str + label: str + condition: str # 예: "doubt_score >= 30" / "default" + canonical: bool = False + description: str = "" + priority: int = 0 # 낮을수록 먼저 평가. default는 마지막 + + +class RCardRef(BaseModel): + model_config = ConfigDict(extra="forbid") + + id: str + trigger: str # identity_direct | weapon + description: str = "" + + +class BeatRange(BaseModel): + model_config = ConfigDict(extra="forbid") + + start: str # "E01" + end: str # "E06" + + +class Chapter(BaseModel): + model_config = ConfigDict(extra="forbid") + + id: str + title: str = "" + summary: str = "" + entry_beat: str # "E01-01" + beat_range: BeatRange + ending_rules: list[EndingRule] + canon_rules: list[str] = Field(default_factory=list) + r_cards: list[RCardRef] = Field(default_factory=list) + pocket_max_turns: int = 6 # 개입 포켓 자유채팅 턴 상한 (강제 수렴) + + +class ChapterFile(BaseModel): + model_config = ConfigDict(extra="forbid") + + chapter: Chapter diff --git a/engine/schemas/character.py b/engine/schemas/character.py new file mode 100644 index 0000000000000000000000000000000000000000..2441dc69280b25f078009fd81d722c15b4977d86 --- /dev/null +++ b/engine/schemas/character.py @@ -0,0 +1,65 @@ +"""인물 정체성 YAML 스키마 (ENOS) — content/characters.yaml. + +kb_query 항목은 pre_kb 상태(status: pending)로 통과시킨다. +""" + +from __future__ import annotations + +from typing import Optional + +from pydantic import BaseModel, ConfigDict, Field + + +class KnowledgeItem(BaseModel): + model_config = ConfigDict(extra="forbid") + + value: Optional[str] = None + source: str = "static" # static | kb_query + query: str = "" + status: str = "" # kb_query면 "pending" (pre_kb) + + +class Role(BaseModel): + model_config = ConfigDict(extra="forbid") + + type: str = "" + description: str = "" + player_controlled: bool = False + perspective: str = "" + + +class Personality(BaseModel): + model_config = ConfigDict(extra="forbid") + + traits: list[str] = Field(default_factory=list) + strengths: list[str] = Field(default_factory=list) + weaknesses: list[str] = Field(default_factory=list) + + +class Speech(BaseModel): + model_config = ConfigDict(extra="forbid") + + style: list[str] = Field(default_factory=list) + tone: list[str] = Field(default_factory=list) + pacing: list[str] = Field(default_factory=list) + + +class CharacterSheet(BaseModel): + # 원본 YAML에 부가 필드가 있어도 콘텐츠를 수정하지 않고 받아들인다 (읽기 전용 원칙) + model_config = ConfigDict(extra="allow") + + id: str + display_name: str + aliases: list[str] = Field(default_factory=list) + role: Role = Field(default_factory=Role) + description: str = "" + personality: Personality = Field(default_factory=Personality) + speech: Speech = Field(default_factory=Speech) + permanent_knowledge: list[KnowledgeItem] = Field(default_factory=list) + immutable_constraints: list[KnowledgeItem] = Field(default_factory=list) + + +class CharactersFile(BaseModel): + model_config = ConfigDict(extra="allow") + + characters: list[CharacterSheet] diff --git a/engine/schemas/validate.py b/engine/schemas/validate.py new file mode 100644 index 0000000000000000000000000000000000000000..678bdb4cbc83ff0455938579561474accdb4e0c8 --- /dev/null +++ b/engine/schemas/validate.py @@ -0,0 +1,147 @@ +"""콘텐츠 검증 CLI — `python -m engine.schemas.validate content/`. + +통과하지 않는 YAML은 엔진에 적재하지 않는다 (CLAUDE.md). +스키마 위반은 수정하지 않고 리포트로 출력한다 (대표님 전달용). + +검사 항목: +1. 3레이어 스키마 검증 (beatcard / chapter / character) + R카드 +2. 링크 무결성: next/next_options/choice.next/preset_branch → 실존 카드 또는 에피소드 참조 +3. 도달성: entry_beat에서 모든 카드 도달 가능 여부 + 챕터 밖(E07)으로의 출구 존재 +4. R카드 참조: 카드가 부르는 R카드가 챕터 등록·YAML 양쪽에 존재 +""" + +from __future__ import annotations + +import re +import sys +from collections import deque +from pathlib import Path + +import yaml + +from engine.schemas.beatcard import BeatCard, EpisodeFile, RCard +from engine.schemas.chapter import ChapterFile +from engine.schemas.character import CharactersFile + +RE_EPISODE_REF = re.compile(r"^E\d{2}$") + + +def load_content(root: Path): + """content/ 전체를 스키마 검증하며 로드. (episodes, r_cards, chapter, characters, errors)""" + errors: list[str] = [] + episodes: list[EpisodeFile] = [] + r_cards: dict[str, RCard] = {} + chapter = None + characters = None + + for f in sorted((root / "beatcards").glob("E*.yaml")): + try: + episodes.append(EpisodeFile.model_validate(yaml.safe_load(f.read_text(encoding="utf-8")))) + except Exception as e: # noqa: BLE001 — 리포트 목적 + errors.append(f"{f.name}: {e}") + for f in sorted((root / "beatcards").glob("R-*.yaml")): + try: + rc = RCard.model_validate(yaml.safe_load(f.read_text(encoding="utf-8"))) + r_cards[rc.id] = rc + except Exception as e: # noqa: BLE001 + errors.append(f"{f.name}: {e}") + for f in sorted((root / "chapters").glob("*.yaml")): + try: + chapter = ChapterFile.model_validate(yaml.safe_load(f.read_text(encoding="utf-8"))).chapter + except Exception as e: # noqa: BLE001 + errors.append(f"{f.name}: {e}") + cf = root / "characters.yaml" + if cf.exists(): + try: + characters = CharactersFile.model_validate(yaml.safe_load(cf.read_text(encoding="utf-8"))) + except Exception as e: # noqa: BLE001 + errors.append(f"characters.yaml: {e}") + else: + errors.append("characters.yaml 없음") + + return episodes, r_cards, chapter, characters, errors + + +def check_links(episodes: list[EpisodeFile], chapter, r_cards) -> tuple[list[str], list[str]]: + """링크 무결성 + 도달성. (errors, warnings)""" + errors: list[str] = [] + warnings: list[str] = [] + cards: dict[str, BeatCard] = {c.id: c for ep in episodes for c in ep.cards} + episode_ids = {ep.episode.id for ep in episodes} + + def ok(target: str) -> bool: + if target in cards: + return True + if RE_EPISODE_REF.match(target): + return True # 에피소드 참조 — 챕터 범위 밖(E07)이면 출구로 취급 + return False + + edges: dict[str, list[str]] = {cid: [] for cid in cards} + for c in cards.values(): + targets = ([c.next] if c.next else []) + c.next_options + for ch in c.choices: + targets += [t for t in (ch.next, ch.preset_branch) if t] + for t in targets: + if not ok(t): + errors.append(f"{c.id} → '{t}' 링크 끊김") + elif t in cards: + edges[c.id].append(t) + elif t in episode_ids: # E02 등 → 해당 에피소드 첫 카드 + first = next(ep.cards[0].id for ep in episodes if ep.episode.id == t) + edges[c.id].append(first) + + if chapter: + if chapter.entry_beat not in cards: + errors.append(f"entry_beat '{chapter.entry_beat}' 카드 없음") + else: + seen = {chapter.entry_beat} + q = deque([chapter.entry_beat]) + while q: + for t in edges[q.popleft()]: + if t not in seen: + seen.add(t) + q.append(t) + unreachable = sorted(set(cards) - seen) + if unreachable: + warnings.append(f"entry에서 도달 불가 카드 {len(unreachable)}개: {', '.join(unreachable)}") + + registered = {r.id for r in chapter.r_cards} + for c in cards.values(): + for ref in c.r_card_refs: + if ref not in registered: + warnings.append(f"{c.id}: R카드 '{ref}' 챕터 미등록") + if ref not in r_cards: + errors.append(f"{c.id}: R카드 '{ref}' YAML 없음") + + # 막다른 카드 (next도 분기도 없는데 챕터 출구도 아님) + for c in cards.values(): + has_out = c.next or c.next_options or any(ch.next for ch in c.choices) + if not has_out: + warnings.append(f"{c.id}: 나가는 링크 없음 (챕터 출구가 아니면 결함)") + + return errors, warnings + + +def main(argv: list[str]) -> int: + root = Path(argv[0]) if argv else Path("content") + episodes, r_cards, chapter, characters, errors = load_content(root) + link_errors, warnings = check_links(episodes, chapter, r_cards) + errors += link_errors + + n_cards = sum(len(ep.cards) for ep in episodes) + print(f"에피소드 {len(episodes)}개 · 카드 {n_cards}개 · R카드 {len(r_cards)}개 · " + f"챕터 {'OK' if chapter else '없음'} · 인물 {len(characters.characters) if characters else 0}명") + + for w in warnings: + print(f" ⚠ {w}") + if errors: + print(f"\n검증 실패 {len(errors)}건:") + for e in errors: + print(f" ✗ {e}") + return 1 + print("✅ 검증 통과") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/engine/services/__init__.py b/engine/services/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/engine/services/__pycache__/__init__.cpython-312.pyc b/engine/services/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4813b6e6b8bbd42e23199128d297a09584c2ceaf Binary files /dev/null and b/engine/services/__pycache__/__init__.cpython-312.pyc differ diff --git a/engine/services/__pycache__/__init__.cpython-314.pyc b/engine/services/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..49dc485e8ee2bc12548459a861995de416594d08 Binary files /dev/null and b/engine/services/__pycache__/__init__.cpython-314.pyc differ diff --git a/engine/services/__pycache__/pocket.cpython-314.pyc b/engine/services/__pycache__/pocket.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..20a4db9d9476bdac33370f14e8f9c94344021bd7 Binary files /dev/null and b/engine/services/__pycache__/pocket.cpython-314.pyc differ diff --git a/engine/services/__pycache__/state.cpython-312.pyc b/engine/services/__pycache__/state.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5f522b8490c38015b5694ed4dba265abf660da63 Binary files /dev/null and b/engine/services/__pycache__/state.cpython-312.pyc differ diff --git a/engine/services/__pycache__/state.cpython-314.pyc b/engine/services/__pycache__/state.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8c74d59fe9f2fbab8bff8db9b1c7e0dd74672edd Binary files /dev/null and b/engine/services/__pycache__/state.cpython-314.pyc differ diff --git a/engine/services/__pycache__/story.cpython-314.pyc b/engine/services/__pycache__/story.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..824f94fd036cbee1ab153d6c4a14f0c8b5dfd9f7 Binary files /dev/null and b/engine/services/__pycache__/story.cpython-314.pyc differ diff --git a/engine/services/pocket.py b/engine/services/pocket.py new file mode 100644 index 0000000000000000000000000000000000000000..99177c0b2c23c1a11bd1775b52cc294818af8dad --- /dev/null +++ b/engine/services/pocket.py @@ -0,0 +1,113 @@ +"""개입 포켓 서비스 — 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")) diff --git a/engine/services/retrieval.py b/engine/services/retrieval.py new file mode 100644 index 0000000000000000000000000000000000000000..eb9f379879c7f0bfc3fe0d936084ecf17e9d68d5 --- /dev/null +++ b/engine/services/retrieval.py @@ -0,0 +1,8 @@ +"""검색 오케스트레이션 — 스포일러 필터 강제 지점 (GraphRAG는 2단계). + +모든 컨텍스트 조회는 `beat_id <= 현재` 필터를 강제한다. +필터 없는 검색 코드는 리뷰에서 반려 (CLAUDE.md). + +MVP: repositories/content.py 메모리 로드 위에서 카드/인물 시트 조회만. +2단계: kb_query 훅(GraphRAG) 활성화 — 이 파일의 인터페이스는 유지한다. +""" diff --git a/engine/services/state.py b/engine/services/state.py new file mode 100644 index 0000000000000000000000000000000000000000..9b919a2f501f0ee7fccbe90f8a35941d143ed2e9 --- /dev/null +++ b/engine/services/state.py @@ -0,0 +1,111 @@ +"""숨은 상태 3값 — 신뢰축 / 쇠약도 / 앎 (P0 4종 엔진 명세 준거). + +규칙: +- 신뢰축: trust_score / doubt_score 누적 (시소). 축 이벤트가 민다 +- 쇠약도: 단조 증가만 허용 (되돌리는 코드는 결함) +- 앎(유저): E01-01 도입에서 1회 확립 후 불변 +- 유저에게 숫자·게이지 노출 금지 — 상태는 톤·결·해금으로만 표출 (trust_level 환산) +- 권위: 포켓 안에서는 session.state, 포켓 종료 시 여기로 커밋 (이중 갱신 금지) +""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +# 축 이벤트 1회당 이동량 (엔진 설정 — 서사 내용 아님). +# E01~E06 축 이벤트 밀도 기준으로 엔딩 3종이 모두 도달 가능하도록 조정된 값. +AXIS_DELTA = 12 + +# 신뢰 레벨 환산 경계 (P0 0-3: 숫자 대신 이 레벨이 톤·결·해금을 결정) +TRUST_LEVELS = ("doubt_high", "doubt_mid", "neutral", "trust_mid", "trust_high") + + +@dataclass +class HiddenState: + trust_score: int = 0 + doubt_score: int = 0 + frailty: int = 0 # 쇠약도 — 단조 증가 + awareness_established: bool = False # 앎(유저) — 도입 1회 확립 + axis_log: list[str] = field(default_factory=list) # 연구용 이력 (card_id:axis) + + # ── 신뢰축 ────────────────────────────────────────────── + def apply_axis(self, axis: str | None, source: str = "") -> None: + if axis == "trust": + self.trust_score += AXIS_DELTA + elif axis == "doubt": + self.doubt_score += AXIS_DELTA + elif axis in (None, "neutral"): + return + else: + raise ValueError(f"알 수 없는 axis: {axis}") + self.axis_log.append(f"{source}:{axis}") + + @property + def seesaw(self) -> int: + """의심우세(-) ←─[0]─→ 신뢰우세(+)""" + return self.trust_score - self.doubt_score + + @property + def trust_level(self) -> str: + """P0 0-3 환산 출력 — 프롬프트의 '결'과 해금을 결정한다.""" + s = self.seesaw + if s >= 36: + return "trust_high" + if s >= 12: + return "trust_mid" + if s <= -36: + return "doubt_high" + if s <= -12: + return "doubt_mid" + return "neutral" + + @property + def deep_unlock(self) -> bool: + """심층 고백 해금 (엔진 D) — 신뢰高에서만.""" + return self.trust_level == "trust_high" + + # ── 쇠약도 ────────────────────────────────────────────── + def advance_frailty(self, amount: int = 1) -> None: + if amount < 0: + raise ValueError("쇠약도는 되돌아가지 않는다") + self.frailty += amount + + # ── 앎 ───────────────────────────────────────────────── + def establish_awareness(self) -> None: + self.awareness_established = True + + # ── 영속화 스냅샷 (전 필드 — 포켓용 to_session과 별개) ── + def snapshot(self) -> dict: + return { + "trust_score": self.trust_score, + "doubt_score": self.doubt_score, + "frailty": self.frailty, + "awareness_established": self.awareness_established, + "axis_log": list(self.axis_log), + } + + @classmethod + def from_snapshot(cls, data: dict) -> "HiddenState": + return cls( + trust_score=int(data["trust_score"]), + doubt_score=int(data["doubt_score"]), + frailty=int(data["frailty"]), + awareness_established=bool(data["awareness_established"]), + axis_log=list(data.get("axis_log", [])), + ) + + # ── 포켓 커밋 (session.state ↔ 영속 상태 단일 경로) ───── + def to_session(self) -> dict: + """포켓 진입 시 session.state에 실어 보낼 스냅샷.""" + return { + "hidden_trust": self.trust_score, + "hidden_doubt": self.doubt_score, + "trust_level": self.trust_level, + "deep_unlock": self.deep_unlock, + } + + def commit_from_session(self, state: dict, source: str = "pocket") -> None: + """포켓 종료 시 session.state의 증분만 커밋 (유일한 역방향 경로).""" + self.trust_score = max(self.trust_score, int(state.get("hidden_trust", self.trust_score))) + self.doubt_score = max(self.doubt_score, int(state.get("hidden_doubt", self.doubt_score))) + self.axis_log.append(f"{source}:commit") diff --git a/engine/services/story.py b/engine/services/story.py new file mode 100644 index 0000000000000000000000000000000000000000..1717064b7764f2a745ba9f75638207eed122f709 --- /dev/null +++ b/engine/services/story.py @@ -0,0 +1,170 @@ +"""카드 진행 상태머신 — 서사 진행의 최종 결정권 (결정적 코드, LLM 없음). + +- entry_beat부터 canon 카드 페이징, next 링크 전이 +- 선택지: axis 반영 + 분기(next/preset_branch) 결정 +- 쇠약도: #메커니즘/쇠약도 카드 진입 시 단조 증가 +- 챕터 출구(E07 등) 도달 시 ending_rules 조건식을 코드로 평가 (LLM 판정 금지) +- 심층 게이트(trust_high) 지문은 신뢰 레벨에 따라 노출 결정 +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass, field +from typing import Optional + +from engine.repositories.content import ContentRepository +from engine.schemas.beatcard import BeatCard, Choice, NarrationBlock +from engine.schemas.chapter import EndingRule +from engine.services.state import HiddenState + +# ending_rules condition에 허용되는 문법: 변수/정수/비교/and (그 외는 검증 실패) +RE_CONDITION_TOKEN = re.compile( + r"^\s*(trust_score|doubt_score)\s*(>=|<=|>|<|==)\s*(\d+)\s*$") + + +def eval_condition(condition: str, state: HiddenState) -> bool: + """조건식 평가 — eval() 금지, 허용 문법만 파싱.""" + if condition.strip() == "default": + return True + values = {"trust_score": state.trust_score, "doubt_score": state.doubt_score} + for clause in condition.split(" and "): + m = RE_CONDITION_TOKEN.match(clause) + if not m: + raise ValueError(f"허용되지 않는 조건식: {condition!r}") + left, op, right = values[m.group(1)], m.group(2), int(m.group(3)) + ok = {"<": left < right, "<=": left <= right, ">": left > right, + ">=": left >= right, "==": left == right}[op] + if not ok: + return False + return True + + +@dataclass +class StepResult: + """한 걸음의 출력 — 프론트/CLI가 그대로 렌더링한다.""" + card: Optional[BeatCard] = None + narration: list[NarrationBlock] = field(default_factory=list) # 게이트 필터 적용분 + choices: list[Choice] = field(default_factory=list) + is_pocket: bool = False + ending: Optional[EndingRule] = None # 챕터 종료 시에만 + + +class StoryEngine: + def __init__(self, repo: ContentRepository, state: HiddenState | None = None): + self.repo = repo + self.state = state or HiddenState() + self.current_id: Optional[str] = None + self.preset_branch: Optional[str] = None # 상류 선택이 예약한 하류 분기 + self.visited: list[str] = [] + self.ending: Optional[EndingRule] = None + + # ── 영속화 (스냅샷/복원) ──────────────────────────────── + def snapshot(self) -> dict: + """JSON 직렬화 가능한 전체 스냅샷 (story + hidden).""" + return { + "story": { + "current_id": self.current_id, + "visited": list(self.visited), + "preset_branch": self.preset_branch, + "ending_id": self.ending.id if self.ending else None, + }, + "hidden": self.state.snapshot(), + } + + @classmethod + def restore(cls, repo: ContentRepository, payload: dict) -> "StoryEngine": + """스냅샷 복원 — _enter()를 거치지 않는다 (쇠약도/축 부수효과 재적용 금지).""" + story = payload["story"] + engine = cls(repo, state=HiddenState.from_snapshot(payload["hidden"])) + engine.current_id = story["current_id"] + engine.visited = list(story["visited"]) + engine.preset_branch = story.get("preset_branch") + ending_id = story.get("ending_id") + if ending_id: + engine.ending = next( + r for r in repo.chapter.ending_rules if r.id == ending_id) + return engine + + def current_step(self) -> StepResult: + """현재 위치의 StepResult 재구성 (렌더 전용 — 상태 무변경).""" + if self.ending: + return StepResult(ending=self.ending) + card = self.repo.get_card(self.current_id) + return StepResult(card=card, narration=self._visible_narration(card), + choices=list(card.choices), is_pocket=card.is_pocket) + + # ── 진행 ──────────────────────────────────────────────── + def start(self) -> StepResult: + return self._enter(self.repo.chapter.entry_beat) + + def _enter(self, card_id: str) -> StepResult: + card = self.repo.get_card(card_id) + self.current_id = card_id + self.visited.append(card_id) + + # 앎 확립 (엔진 A): 챕터 진입 카드에서 1회 + if card_id == self.repo.chapter.entry_beat: + self.state.establish_awareness() + # 쇠약도 (신뢰와 독립 — 핵심 아이러니) + if card.frailty: + self.state.advance_frailty() + # 카드 자체 축 — 캐논 이벤트 (예: E06-10 '첫 진짜 의심'은 모든 경로에 적용). + # 단, 선택지들이 축을 갖고 있으면 선택이 결정하므로 중복 적용하지 않는다 + if card.axis and not any(ch.axis for ch in card.choices): + self.state.apply_axis(card.axis, source=card_id) + + return StepResult(card=card, narration=self._visible_narration(card), + choices=list(card.choices), is_pocket=card.is_pocket) + + def advance(self) -> StepResult: + """선택지 없는 카드에서 다음으로 (책장 넘기기).""" + card = self.repo.get_card(self.current_id) + if card.choices: + raise RuntimeError(f"{card.id}: 선택지가 있는 카드 — choose()를 쓸 것") + return self._goto(card.next, card) + + def choose(self, index: int) -> tuple[Choice, StepResult]: + """선택지 반영 — axis 적용 + 분기 결정.""" + card = self.repo.get_card(self.current_id) + if not card.choices: + raise RuntimeError(f"{card.id}: 선택지 없는 카드") + choice = card.choices[index] + + self.state.apply_axis(choice.axis, source=f"{card.id}[{index}]") + if choice.preset_branch: + self.preset_branch = choice.preset_branch + + # 분기 카드: preset이 예약돼 있으면 그것이 우선 (상류 커밋 존중) + target = choice.next + if card.next_options: + if self.preset_branch in card.next_options: + target = self.preset_branch + self.preset_branch = None + elif target is None: + raise RuntimeError(f"{card.id}: 분기 카드인데 선택지에 next 없음") + + return choice, self._goto(target or card.next, card) + + def _goto(self, target: Optional[str], from_card: BeatCard) -> StepResult: + if target is None: + # 나가는 링크 없음 — 콘텐츠 검증에서 경고된 상태. 챕터 종료로 처리 + return self._finish() + resolved = self.repo.resolve_ref(target) + if resolved is None: # E07 등 챕터 범위 밖 → 챕터 종료 + return self._finish() + return self._enter(resolved) + + # ── 엔딩 (결정적 코드) ────────────────────────────────── + def _finish(self) -> StepResult: + rules = sorted(self.repo.chapter.ending_rules, key=lambda r: r.priority) + for rule in rules: + if eval_condition(rule.condition, self.state): + self.ending = rule + return StepResult(ending=rule) + raise RuntimeError("default 엔딩 규칙 없음 — 콘텐츠 결함") + + # ── 지문 게이트 (엔진 D: 심층 해금) ───────────────────── + def _visible_narration(self, card: BeatCard) -> list[NarrationBlock]: + return [b for b in card.narration + if b.gate == "all" or (b.gate == "trust_high" and self.state.deep_unlock)] diff --git a/engine/sub_agents/__init__.py b/engine/sub_agents/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/engine/sub_agents/__pycache__/__init__.cpython-312.pyc b/engine/sub_agents/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8ff93e0d48cd3bdc4dafb77983cb9982603f43d2 Binary files /dev/null and b/engine/sub_agents/__pycache__/__init__.cpython-312.pyc differ diff --git a/engine/sub_agents/__pycache__/__init__.cpython-314.pyc b/engine/sub_agents/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..bb7fa92b2140cf0863086565f078b357979bd7fb Binary files /dev/null and b/engine/sub_agents/__pycache__/__init__.cpython-314.pyc differ diff --git a/engine/sub_agents/character/__init__.py b/engine/sub_agents/character/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/engine/sub_agents/character/__pycache__/__init__.cpython-312.pyc b/engine/sub_agents/character/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..bad8b2e7451a4f02058b35897ecefaa6e511845d Binary files /dev/null and b/engine/sub_agents/character/__pycache__/__init__.cpython-312.pyc differ diff --git a/engine/sub_agents/character/__pycache__/__init__.cpython-314.pyc b/engine/sub_agents/character/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a2f6bc2ba4dc5c58bbf0bcd28d7a212276ce5ac4 Binary files /dev/null and b/engine/sub_agents/character/__pycache__/__init__.cpython-314.pyc differ diff --git a/engine/sub_agents/character/__pycache__/agent.cpython-312.pyc b/engine/sub_agents/character/__pycache__/agent.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a85e0d3a124de2f239fa3e1da972e291ad095232 Binary files /dev/null and b/engine/sub_agents/character/__pycache__/agent.cpython-312.pyc differ diff --git a/engine/sub_agents/character/__pycache__/agent.cpython-314.pyc b/engine/sub_agents/character/__pycache__/agent.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..98c9f9efed83da0acb0394ddcda1ddcd8dbb6707 Binary files /dev/null and b/engine/sub_agents/character/__pycache__/agent.cpython-314.pyc differ diff --git a/engine/sub_agents/character/agent.py b/engine/sub_agents/character/agent.py new file mode 100644 index 0000000000000000000000000000000000000000..490f76d39672f099ed48b79705d15f71050f08a3 --- /dev/null +++ b/engine/sub_agents/character/agent.py @@ -0,0 +1,25 @@ +"""character — 카르밀라 응답 (LlmAgent, CHARACTER_MODEL). + +instruction은 engine/instructions/character_carmilla.md. +{carmilla_sheet}/{pocket_brief}/{trust_texture}/{guard_directive}는 +loader/guard가 매 턴 session.state에 채운다 (치환값은 중괄호 위생 처리됨). +""" + +from __future__ import annotations + +from pathlib import Path + +from google.adk.agents import LlmAgent + +from engine.model import CHARACTER_MODEL + +_INSTRUCTION = (Path(__file__).resolve().parents[2] / "instructions" / "character_carmilla.md") + + +def create_character(model=None) -> LlmAgent: + return LlmAgent( + name="carmilla", + model=model or CHARACTER_MODEL, + instruction=_INSTRUCTION.read_text(encoding="utf-8"), + output_key="last_carmilla_reply", + ) diff --git a/engine/sub_agents/director/__init__.py b/engine/sub_agents/director/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/engine/sub_agents/director/__pycache__/__init__.cpython-312.pyc b/engine/sub_agents/director/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3b37e73dafe681f9fb5e3e479d10f7a087da1120 Binary files /dev/null and b/engine/sub_agents/director/__pycache__/__init__.cpython-312.pyc differ diff --git a/engine/sub_agents/director/__pycache__/__init__.cpython-314.pyc b/engine/sub_agents/director/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..cebe571b020755e8044a3ae735a19c0949c93e8d Binary files /dev/null and b/engine/sub_agents/director/__pycache__/__init__.cpython-314.pyc differ diff --git a/engine/sub_agents/director/__pycache__/agent.cpython-312.pyc b/engine/sub_agents/director/__pycache__/agent.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1c69db1ddf1faa5bdab9b016938b46b3bb5b812d Binary files /dev/null and b/engine/sub_agents/director/__pycache__/agent.cpython-312.pyc differ diff --git a/engine/sub_agents/director/__pycache__/agent.cpython-314.pyc b/engine/sub_agents/director/__pycache__/agent.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2328c2d830770ce6506eb9109058da6f2642fd09 Binary files /dev/null and b/engine/sub_agents/director/__pycache__/agent.cpython-314.pyc differ diff --git a/engine/sub_agents/director/agent.py b/engine/sub_agents/director/agent.py new file mode 100644 index 0000000000000000000000000000000000000000..bbbd5c04eca07d07ef72792ced43962544f67fe9 --- /dev/null +++ b/engine/sub_agents/director/agent.py @@ -0,0 +1,28 @@ +"""director — 신뢰축/비트/수렴 판정 (LlmAgent). + +구조화 값은 텍스트가 아니라 FunctionTool이 session.state에 직접 기록한다 +(output_key 구조화 기대 금지 / output_schema+tools 동시 사용 금지 — CLAUDE.md). +수렴 선언(finish_pocket)은 escalate만 세우고, 최종 전이는 services/pocket.py가 재검증한다. +""" + +from __future__ import annotations + +from pathlib import Path + +from google.adk.agents import LlmAgent + +from engine.model import LLM_MODEL +from engine.tools.state.finish_pocket import finish_pocket +from engine.tools.state.mark_beat import mark_beat +from engine.tools.state.update_trust import update_trust + +_INSTRUCTION = Path(__file__).resolve().parents[2] / "instructions" / "director.md" + + +def create_director(model=None) -> LlmAgent: + return LlmAgent( + name="director", + model=model or LLM_MODEL, + instruction=_INSTRUCTION.read_text(encoding="utf-8"), + tools=[update_trust, mark_beat, finish_pocket], + ) diff --git a/engine/tools/__init__.py b/engine/tools/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/engine/tools/__pycache__/__init__.cpython-312.pyc b/engine/tools/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..de7a69d177cd9e3bc81d040a7ada9169c75af23a Binary files /dev/null and b/engine/tools/__pycache__/__init__.cpython-312.pyc differ diff --git a/engine/tools/__pycache__/__init__.cpython-314.pyc b/engine/tools/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..84c50d0ed88654356aa62d92348d15f1cb01b982 Binary files /dev/null and b/engine/tools/__pycache__/__init__.cpython-314.pyc differ diff --git a/engine/tools/retrieval/__init__.py b/engine/tools/retrieval/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/engine/tools/state/__init__.py b/engine/tools/state/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/engine/tools/state/__pycache__/__init__.cpython-312.pyc b/engine/tools/state/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..fc53d71a51e9e914bb8c42faa4808c25e0b7f37e Binary files /dev/null and b/engine/tools/state/__pycache__/__init__.cpython-312.pyc differ diff --git a/engine/tools/state/__pycache__/__init__.cpython-314.pyc b/engine/tools/state/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9f75fb52bdda61eac7fa3723cda5ab7cf56b4188 Binary files /dev/null and b/engine/tools/state/__pycache__/__init__.cpython-314.pyc differ diff --git a/engine/tools/state/__pycache__/finish_pocket.cpython-312.pyc b/engine/tools/state/__pycache__/finish_pocket.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d37795a311fe440d732a7fcde9e598acc5e6cfd2 Binary files /dev/null and b/engine/tools/state/__pycache__/finish_pocket.cpython-312.pyc differ diff --git a/engine/tools/state/__pycache__/finish_pocket.cpython-314.pyc b/engine/tools/state/__pycache__/finish_pocket.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..75b71732d2a357b9fa49aad6ae696497426d793e Binary files /dev/null and b/engine/tools/state/__pycache__/finish_pocket.cpython-314.pyc differ diff --git a/engine/tools/state/__pycache__/mark_beat.cpython-312.pyc b/engine/tools/state/__pycache__/mark_beat.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3be7a90a6d5377598385144a7b807872da338ba1 Binary files /dev/null and b/engine/tools/state/__pycache__/mark_beat.cpython-312.pyc differ diff --git a/engine/tools/state/__pycache__/mark_beat.cpython-314.pyc b/engine/tools/state/__pycache__/mark_beat.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..22eab7e11bb4667abc81a5a1d0c3fb5b1040839a Binary files /dev/null and b/engine/tools/state/__pycache__/mark_beat.cpython-314.pyc differ diff --git a/engine/tools/state/__pycache__/update_trust.cpython-312.pyc b/engine/tools/state/__pycache__/update_trust.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..bf5f7d4c513852d90c49b6e7fbb52ee3d8837208 Binary files /dev/null and b/engine/tools/state/__pycache__/update_trust.cpython-312.pyc differ diff --git a/engine/tools/state/__pycache__/update_trust.cpython-314.pyc b/engine/tools/state/__pycache__/update_trust.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1cb3d56ae41f58e092d0b603c4f7ce62b3bcd200 Binary files /dev/null and b/engine/tools/state/__pycache__/update_trust.cpython-314.pyc differ diff --git a/engine/tools/state/finish_pocket.py b/engine/tools/state/finish_pocket.py new file mode 100644 index 0000000000000000000000000000000000000000..ade101bf78c7c5a94995150cefb6b139f1565c87 --- /dev/null +++ b/engine/tools/state/finish_pocket.py @@ -0,0 +1,19 @@ +"""finish_pocket — 수렴 판정 (1파일 = 1함수, FunctionTool). + +escalate로 이번 턴 파이프라인을 종료 신호로 표시한다. 최종 전이는 +services/pocket.py의 코드 재검증을 거쳐야만 확정된다 (LLM 출력만으로 전이 금지). +""" + +from google.adk.tools import ToolContext + + +def finish_pocket(reason: str, tool_context: ToolContext) -> dict: + """장면 수렴을 선언한다. + + Args: + reason: "goal_reached" | "drift" | "stall" + """ + tool_context.state["pocket_converged"] = True + tool_context.state["pocket_converge_reason"] = reason + tool_context.actions.escalate = True + return {"status": "ok", "reason": reason} diff --git a/engine/tools/state/mark_beat.py b/engine/tools/state/mark_beat.py new file mode 100644 index 0000000000000000000000000000000000000000..c15c462b7bc36d93cef631e3a7c5c5eae1a10941 --- /dev/null +++ b/engine/tools/state/mark_beat.py @@ -0,0 +1,16 @@ +"""mark_beat — 목표 비트 달성 기록 (1파일 = 1함수, FunctionTool).""" + +from google.adk.tools import ToolContext + + +def mark_beat(beat: str, tool_context: ToolContext) -> dict: + """장면 목표 비트가 달성되었음을 기록한다. + + Args: + beat: 달성된 비트의 짧은 식별 문구 + """ + hits = list(tool_context.state.get("pocket_beats_hit", [])) + if beat not in hits: + hits.append(beat) + tool_context.state["pocket_beats_hit"] = hits + return {"status": "ok", "beats_hit": hits} diff --git a/engine/tools/state/update_trust.py b/engine/tools/state/update_trust.py new file mode 100644 index 0000000000000000000000000000000000000000..5087fd110019bd5558ae334c700bd5feba444b62 --- /dev/null +++ b/engine/tools/state/update_trust.py @@ -0,0 +1,26 @@ +"""update_trust — 신뢰축 이동 (1파일 = 1함수, FunctionTool). + +session.state의 숨은 값만 움직인다. 영속 커밋은 포켓 종료 시 +services/pocket.py → services/state.py 경로가 담당 (이중 갱신 금지). +""" + +from google.adk.tools import ToolContext + +from engine.services.state import AXIS_DELTA + + +def update_trust(direction: str, tool_context: ToolContext) -> dict: + """유저 발화 태도 판정에 따라 신뢰축을 민다. + + Args: + direction: "trust" | "doubt" | "neutral" + """ + if direction == "trust": + tool_context.state["hidden_trust"] = tool_context.state.get("hidden_trust", 0) + AXIS_DELTA + elif direction == "doubt": + tool_context.state["hidden_doubt"] = tool_context.state.get("hidden_doubt", 0) + AXIS_DELTA + elif direction != "neutral": + return {"status": "error", "message": f"unknown direction: {direction}"} + turns = tool_context.state.get("pocket_axis_calls", 0) + 1 + tool_context.state["pocket_axis_calls"] = turns + return {"status": "ok", "direction": direction}