# sorac 카르밀라 — 전체 구조 명세서 > 이 문서 하나로 "무슨 파일이 어디 있고, 어떤 역할이며, 어떻게 맞물려 도는지"를 파악할 수 있게 쓴 명세서. > 마지막 갱신: 2026-07-03 (MVP M0~M6 완료 시점) --- ## 0. 한 문장 요약 **비트카드(대표님이 쓴 서사 원고)를 YAML로 변환해 적재하고, 파이썬 상태머신이 "책 읽기"를, google-adk LLM 파이프라인이 "카르밀라와의 대화"를 담당하며, 웹 리더가 그걸 화면에 그린다.** ``` [대표님 비트카드 MD] ──변환──▶ [content/ YAML] ──적재──▶ ┌─ StoryEngine (읽기: 결정적 코드) └─ PocketService (대화: ADK LLM) │ [web/index.html] ◀──HTTP── [router.py] ``` 핵심 설계 원칙 (CLAUDE.md): - **서사 진행은 코드가, 대사 생성만 LLM이** 한다 — 스토리가 산으로 가지 않게 - **숨은 상태(신뢰/의심/쇠약)는 유저에게 절대 숫자로 노출하지 않는다** - **콘텐츠(YAML)와 코드의 분리** — 이야기 수정은 콘텐츠 쪽에서만 --- ## 1. 루트 폴더 지도 | 경로 | 누가 만든 것 | 역할 | |---|---|---| | `CLAUDE.md` | 공동 | 개발 헌법. 아키텍처 규칙·금지사항. Claude Code가 매 세션 읽음 | | `engine/` | Claude | **엔진 본체** (파이썬 패키지) — 아래 §3 | | `content/` | 변환기 산출물 | **엔진이 읽는 서사 데이터** (YAML) — 아래 §2 | | `web/` | Claude | 웹 리더 프론트 (단일 HTML) | | `scripts/` | Claude | 개발용 스크립트 (변환기, CLI 플레이어) | | `tests/` | Claude | 자동 테스트 18개 | | `docs/` | Claude | 계획서·API 계약·본 명세서 | | `logs/` | 런타임 생성 | 플레이 턴 로그 (연구 데이터, git 제외) | | `data/` | 런타임 생성 | 플레이 세션 영속화 SQLite (`sessions.db`, git 제외) | | `.env` | 유저 | OPENAI_API_KEY (git 제외 — 절대 커밋 금지) | | `.venv/` | 설치 산출물 | 파이썬 가상환경 (google-adk 2.3.0 등) | | `카르밀라/` | 대표님 | **콘텐츠 원천** — 비트카드 MD·원고·기획·이미지 | | `reference/` | 대표님(프로토타입) | 비-ADK 개념검증 엔진. **참고용, 실행 안 함** | | `requirements.txt` / `pyproject.toml` | Claude | 의존성 목록 / pytest 설정 | --- ## 2. content/ — 엔진이 읽는 서사 데이터 (3레이어) **엔진의 유일한 서사 입력.** 원천은 `카르밀라/0701_카르밀라/비트카드/`의 MD 파일(대표님 소유)이고, 여기 있는 YAML은 `scripts/beatcard_convert.py`가 만든 **파생물**이다. 이야기를 고치려면 MD를 고치고 변환기를 다시 돌린다 (YAML 직접 수정 금지). | 파일 | 레이어 | 내용 | |---|---|---| | `content/beatcards/E01.yaml` ~ `E06.yaml` | 내러티브 | **비트카드 61장.** 카드 = 화면 한 장. 지문 블록(prose/중앙대사/인물대사/무대지시), 선택지(라벨·결과산문·신뢰축·분기), next 링크, 태그 | | `content/beatcards/R-회피사다리.yaml` | 내러티브 | 유저가 정체를 직격했을 때 카르밀라의 3단계 회피 대응 (재사용 카드) | | `content/beatcards/R-약점반응.yaml` | 내러티브 | 성물·마늘 등 약점 언급 시 반응 원칙 | | `content/chapters/carmilla_chapter1.yaml` | 챕터 메타 | 시작 카드(E01-01), 범위(E01~E06), **엔딩 3종 규칙**, 캐논 규칙, R카드 등록, 채팅 턴 상한(6) | | `content/characters.yaml` | 정체성 | 인물 시트 3명(로라=플레이어, 카르밀라, 아버지). 성격·화법·지식경계 | **카드 한 장의 구조 (예)**: ```yaml - id: E06-09 # 카드 ID title: 둘만의 비밀 canon: true # 원작 뼈대 (불변) next: E06-10 # 다음 카드 axis: null # 카드 진입 시 신뢰축 이동 (trust/doubt/null) frailty: false # 이 카드에서 쇠약도 +1 여부 narration: # 지문 블록들 (화면에 그대로 출력) - {type: prose, text: "..."} - {type: dialogue_center, text: "죽음은 오히려 축복일 수도 있어."} - {type: dialogue_center, text: "...시작이야.", gate: trust_high} # 신뢰高 전용(심층 해금) - {type: character_dialogue, speaker: 카르밀라, text: "...", anchor: true} # 채팅 변주 기준 choices: - {label: 둘만의 비밀에 동조한다, axis: trust, result: "...", next: null} - {label: 그래도 누군가에게 알려야..., axis: doubt, result: "...", next: null} ``` --- ## 3. engine/ — 엔진 본체 ### 3-1. 최상위 파일 | 파일 | 역할 | |---|---| | `model.py` | LLM 설정. 루트 `.env`를 로딩하고 LiteLLM으로 모델 초기화 (`LLM_MODEL`/`CHARACTER_MODEL` env로 교체 가능, 기본 openai/gpt-5.4-nano) | | `agent.py` | **ADK 파이프라인 조립**: `pocket_session = Sequential[로더 → 가드 → 카르밀라 → 디렉터]`. `adk web`용 `root_agent`도 여기서 노출 | | `app.py` | ADK `App`(이름+루트에이전트+플러그인) + `Runner`(세션 서비스) 생성 | | `router.py` | **FastAPI 서버.** HTTP 엔드포인트(세션/advance/choose/pocket) + 웹 리더·이미지 서빙. 연출 이미지 매핑(`IMAGE_MAP`)도 여기 | ### 3-2. services/ — 비즈니스 로직 (게임 규칙의 심장) | 파일 | 역할 | |---|---| | `story.py` | **읽기 상태머신.** 카드 전이(next), 선택지 반영, 심층 게이트 노출 판정, **엔딩 조건식 평가**(전용 파서 — eval 금지). 도달점 보장(preset_branch) 로직 포함 | | `state.py` | **숨은 상태 3값.** 신뢰축 시소(trust/doubt, 이벤트당 ±12), 쇠약도(단조 증가만 허용), 앎(도입 1회 확립). 신뢰 레벨 환산(±12/±36 경계 → 5단계) — 이 레벨이 카르밀라의 "결"과 심층 해금을 결정 | | `pocket.py` | **채팅 세션 관리.** 포켓 열기(카르밀라 카드만)/턴 실행/닫기(상태 커밋). **수렴 재검증**: LLM이 "끝내자"고 해도 코드가 검사 — 최소 3턴 체류, 비트 없는 종료 기각, 6턴 강제 수렴 | | `paging.py` | **페이지 병합.** 여러 카드를 한 화면으로 — 결정적 파티션(선형 링크 구간만, 선택지·연출 컷·채팅 앵커·에피소드 경계에서 분리, `page_target_chars` 소프트 캡). 부수효과는 여전히 story.py가 카드당 1회 | | `retrieval.py` | (2단계 스텁) GraphRAG 검색 자리. 스포일러 필터 강제 지점 | ### 3-3. sub_agents/ + agents/ — ADK 대화 파이프라인 (채팅 한 턴에 4명이 순서대로 일함) ``` 유저 입력 → ① pocket_context_loader → ② input_guard → ③ carmilla → ④ director → 응답 ``` | 순서 | 파일 | 타입 | 하는 일 | |---|---|---|---| | ① | `agents/pocket_context_loader.py` | 코드(BaseAgent) | 현재 카드의 지문·앵커대사·신뢰 레벨별 "결"을 세션에 적재. **캐릭터용 브리프와 디렉터용 노트를 분리** (스포일러 누수 방지) | | ② | `agents/input_guard.py` | 코드(BaseAgent) | 유저 입력 검사: 정체 직격("흡혈귀지?") → R-회피사다리 단계 지시, 약점(십자가 등) → R-약점반응 지시, 메타 발화(AI/게임) → 세계관 복귀 지시 | | ③ | `sub_agents/character/agent.py` | **LLM** | 카르밀라 응답 생성. instruction = `instructions/character_carmilla.md` + 인물시트 + 장면브리프 + 가드지시 | | ④ | `sub_agents/director/agent.py` | **LLM** | 심판. 유저에게 안 보임. 도구 호출로만 기록: 신뢰축 이동/목표 비트 달성/수렴 선언 | ### 3-4. tools/state/ — 디렉터가 쓰는 도구 3종 (1파일 1함수) | 파일 | 기능 | |---|---| | `update_trust.py` | 이번 턴 대화로 신뢰/의심 축을 ±12 이동 | | `mark_beat.py` | 장면 목표 비트 달성 기록 | | `finish_pocket.py` | 수렴 선언(escalate) — 단 **최종 결정은 pocket.py 코드가 재검증** | ### 3-5. 나머지 | 경로 | 역할 | |---|---| | `schemas/` | 콘텐츠 YAML의 Pydantic 스키마(beatcard/chapter/character) + **validate CLI** (`python -m engine.schemas.validate content/` — 스키마·링크 무결성·도달성 검사) | | `repositories/content.py` | YAML → 메모리 적재. **스포일러 필터**(현재 카드 이후 조회 차단). 카드 순서/전체 수(진행도용). 콘텐츠 버전 스탬프(`content_version`) | | `repositories/playlog.py` | 턴 로그 jsonl 저장 (`logs/playlog.jsonl`, 연구 데이터) | | `repositories/play_session.py` | **세션 영속화** — 서사 위치+숨은 상태 스냅샷을 SQLite(`data/sessions.db`)에 매 변경 즉시 저장. 서버 재시작 후 복원(포켓 채팅은 비영속·폐기). 콘텐츠 버전 불일치 세션은 무효화 | | `plugins/logging.py` | ADK 플러그인 — 채팅의 유저입력/응답/도구호출/상태변화를 자동 기록 | | `instructions/` | LLM 프롬프트 원문 (카르밀라 인격 규칙, 디렉터 판정 절차). **말투·행동 규칙을 고치려면 여기** | | `callbacks/` | (빈 스캐폴드) before_model 스포일러 최종 방어선 자리 — 2단계 | --- ## 4. web/ — 웹 리더 (프론트엔드) `web/index.html` 단일 파일 (바닐라 JS, 빌드 없음). router.py가 서빙. - **읽기**: 화면 단위 페이징 — 스크롤 없이 넘김으로만 읽는다. 서버 페이지(여러 카드 병합, `paging.py`가 run별 균형 분할 — 목표 500자, 병합 페이지 밴드 약 360~760자)를 프론트가 뷰포트 실측(`#page-measure`)으로 화면들로 분할(`buildScreens`), 같은 진행도 번호를 공유. 우측 클릭/→ = 다음, 좌측 클릭/← = 이전(읽기 전용 되돌아보기 — 선택 번복 불가). 연출 컷(이미지)은 자체 번호를 가진 독립 페이지 - **타이틀**: 에피소드 첫 페이지의 "제N화" 헤더만 표시 — 카드 소제목은 렌더하지 않는다(책의 흐름). 컷 캡션은 유지 - **이어 읽기**: 세션 ID를 `localStorage`에 보관 — 새로고침·서버 재시작 후에도 현재 페이지부터 재개 (표지 버튼이 "밤을 잇는다"로 바뀜). 엔딩에서 재시작하면 폐기 - **캐릭터 발화(하이브리드)**: 서사 속 `character_dialogue`는 컴팩트 char-line(좌 화자명 · 우 대사)으로 본문에 흡수 — 책의 흐름 우선. 유저·로라는 "나"로 표기. `dialogue_center`는 중앙 인용 스타일 유지 - **VN 스테이지**: 인터랙션 페이지(선택지·채팅)의 마지막 초상 발화만 스테이지(단독 화면) — 초상이 프레임(76vh)을 채우고 하단 패널에 장식 이름표·대사, 마지막 그룹이면 선택지·말걸기 버튼 내장. **발화 직전의 짧은 도입 prose(role=lead_in, 140자 이하 자동 유도)는 스테이지 상단에 얹힌다** — 한 문장 파편 화면 방지. 채팅 드로어가 열리면 초상 위에서 대화하는 구도 (참고: 플래이 이미지/7.png) - **선택지**: `›` 마커 + 가는 구분선 리스트 (대화하듯). 선택 결과 산문 표시 후 우측 클릭으로 진행 - **채팅**: 카르밀라 카드에서 "…말을 건다" → 하단 드로어. 수렴 시 여운(3.4초) 후 자동 복귀 - **연출**: 상단 진행 바(n/총페이지 = 카드 61 + 컷 5 = 66), 중요 장면 이미지 페이지, 표지 - 이미지 원본: `카르밀라/0630…/이미지/` (router의 `/assets`로 서빙, 매핑은 `IMAGE_MAP`) --- ## 5. scripts/ · tests/ · docs/ | 경로 | 역할 | |---|---| | `scripts/beatcard_convert.py` | **비트카드 MD → content/ YAML 변환기.** 선택지-결과 매칭, 분기 해석, 도달점 우회 방지. 콘텐츠 갱신 시 재실행 | | `scripts/play.py` | 터미널 플레이어. `--policy trust/doubt/neutral --quiet`로 엔딩 3종 자동 검증 가능 | | `tests/` (18개) | `test_story.py` 읽기/엔딩/스포일러 · `test_pocket.py` 채팅 파이프라인(가짜 LLM으로 결정적 검증) · `test_integration.py` 읽기→채팅→엔딩 전체 관통 · `fakes.py` 스크립트 LLM 테스트 더블 | | `docs/plans/mvp.md` | 마일스톤 M0~M6 계획·상태·결정 로그 | | `docs/contract/demo_api.md` | 웹 리더 ↔ 서버 HTTP API 계약 | | `docs/contract/beatcard_guide.md` | 비트카드 작성 가이드 (권장 분량·3막 패턴·리드인 규칙 — E07~ 집필 참고, validate 경고와 연동) | | `docs/ARCHITECTURE.md` | 본 문서 | --- ## 6. 런타임 흐름 (실제로 어떻게 도나) ### 읽기 턴 (LLM 없음, 0원) ``` [폰/브라우저] 우측 클릭 → POST /api/session/{id}/advance (router.py) → StoryEngine.advance() (story.py) 카드 next 따라 이동 · 쇠약도/신뢰축 반영 · 심층 게이트 판정 → 카드 JSON 응답 → 화면 렌더 ``` ### 선택 턴 (LLM 없음) ``` 선택지 클릭 → POST /choose {index} → StoryEngine.choose(): 신뢰축 ±12 · 분기(next/preset) 결정 · 결과 산문 반환 ``` ### 채팅 턴 (LLM 2회 호출 — 카르밀라 1 + 디렉터 1) ``` "…말을 건다" → POST /pocket/open → PocketService.open() (카르밀라 카드 검증 + 상태 시딩) 메시지 전송 → POST /pocket/turn → ADK Runner: 로더 → 가드 → 카르밀라(LLM) → 디렉터(LLM+도구) → PocketService가 수렴 재검증 (3턴 미만 기각 / 6턴 강제 종료) → 수렴 시: 숨은 상태를 본편에 커밋하고 읽기로 복귀 ``` ### 엔딩 (LLM 없음) ``` E06 끝(→E07 참조) 도달 → story.py가 ending_rules 평가: doubt ≥ 30 → ① 처단 (원작 캐논) trust ≥ 60 & doubt<20 → ② 영원 (다크 로맨스) 그 외 → ③ 구원 (이별) ``` --- ## 7. 실행 명령 모음 ```bash # 웹 리더 (폰 접속 허용) .venv/bin/uvicorn engine.router:app --host 0.0.0.0 --port 8777 # 터미널 플레이 / 자동 엔딩 검증 .venv/bin/python scripts/play.py .venv/bin/python scripts/play.py --policy trust --quiet # 콘텐츠 갱신 (비트카드 MD 수정 후) .venv/bin/python scripts/beatcard_convert.py "카르밀라/0701_카르밀라/비트카드" -o content/beatcards .venv/bin/python -m engine.schemas.validate content/ # 테스트 .venv/bin/python -m pytest tests/ -q ``` --- ## 8. "이걸 바꾸고 싶으면 어디를 보나" 치트시트 | 하고 싶은 것 | 보는 곳 | |---|---| | 이야기 본문·선택지 수정 | 비트카드 MD (`카르밀라/0701…/비트카드/`) → 변환기 재실행 | | 카르밀라 말투·행동 규칙 | `engine/instructions/character_carmilla.md` | | 대화 종료(수렴) 판정 기준 | `engine/instructions/director.md` + `engine/services/pocket.py` (MIN_TURNS 등) | | 엔딩 조건 숫자 | `content/chapters/carmilla_chapter1.yaml` (ending_rules) | | 신뢰축 이동량·레벨 경계 | `engine/services/state.py` (AXIS_DELTA, trust_level) | | 채팅 턴 상한 | `content/chapters/carmilla_chapter1.yaml` (pocket_max_turns) | | 페이지당 글 밀도 | `content/chapters/carmilla_chapter1.yaml`에 `page_target_chars` (미설정 시 500) | | 연출 이미지 추가·교체 | `engine/router.py` (IMAGE_MAP) + `scripts/sync_assets.py` 재실행 (원천: 카르밀라/0701_카르밀라/이미지/) | | 장소별 배경 추가·교체 | `engine/router.py` (BACKGROUND_MAP — 카드 `#장소/` 태그 기준) + sync_assets | | 화자 초상 추가·교체 (VN 발화) | `engine/router.py` (SPEAKER_PORTRAIT) + sync_assets | | 인트로 시퀀스(작품·인물 소개) 교체 | `engine/router.py` (INTRO_IMAGES) | | 화면 디자인·인터랙션 | `web/index.html` | | 세션 저장·복원 정책 (TTL, 버전 무효화) | `engine/repositories/play_session.py` + `engine/router.py` (`_save`/`_restore`) | | 정체직격/약점 트리거 단어 | `engine/agents/input_guard.py` (TRIGGERS) | | LLM 모델 교체 | `.env`에 `LLM_MODEL=...` / `CHARACTER_MODEL=...` |