Spaces:
Sleeping
Sleeping
Upload 113 files
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- docs/ARCHITECTURE.md +235 -0
- docs/contract/demo_api.md +73 -0
- docs/plans/mvp.md +92 -0
- docs/plans/persistence.md +112 -0
- engine/__pycache__/agent.cpython-312.pyc +0 -0
- engine/__pycache__/agent.cpython-314.pyc +0 -0
- engine/__pycache__/app.cpython-312.pyc +0 -0
- engine/__pycache__/app.cpython-314.pyc +0 -0
- engine/__pycache__/model.cpython-312.pyc +0 -0
- engine/__pycache__/model.cpython-314.pyc +0 -0
- engine/__pycache__/router.cpython-312.pyc +0 -0
- engine/__pycache__/router.cpython-314.pyc +0 -0
- engine/agents/__pycache__/input_guard.cpython-312.pyc +0 -0
- engine/agents/__pycache__/input_guard.cpython-314.pyc +0 -0
- engine/agents/__pycache__/pocket_context_loader.cpython-312.pyc +0 -0
- engine/agents/__pycache__/pocket_context_loader.cpython-314.pyc +0 -0
- engine/plugins/__pycache__/__init__.cpython-312.pyc +0 -0
- engine/plugins/__pycache__/logging.cpython-312.pyc +0 -0
- engine/plugins/__pycache__/logging.cpython-314.pyc +0 -0
- engine/repositories/__pycache__/content.cpython-312.pyc +0 -0
- engine/repositories/__pycache__/content.cpython-314.pyc +0 -0
- engine/repositories/__pycache__/play_session.cpython-312.pyc +0 -0
- engine/repositories/__pycache__/play_session.cpython-314.pyc +0 -0
- engine/repositories/__pycache__/playlog.cpython-312.pyc +0 -0
- engine/repositories/__pycache__/playlog.cpython-314.pyc +0 -0
- engine/repositories/content.py +4 -0
- engine/router.py +28 -33
- engine/schemas/__pycache__/beatcard.cpython-312.pyc +0 -0
- engine/schemas/__pycache__/beatcard.cpython-314.pyc +0 -0
- engine/schemas/__pycache__/chapter.cpython-312.pyc +0 -0
- engine/schemas/__pycache__/chapter.cpython-314.pyc +0 -0
- engine/schemas/__pycache__/character.cpython-312.pyc +0 -0
- engine/schemas/__pycache__/character.cpython-314.pyc +0 -0
- engine/schemas/__pycache__/validate.cpython-312.pyc +0 -0
- engine/schemas/__pycache__/validate.cpython-314.pyc +0 -0
- engine/schemas/chapter.py +1 -0
- engine/services/__pycache__/paging.cpython-312.pyc +0 -0
- engine/services/__pycache__/paging.cpython-314.pyc +0 -0
- engine/services/__pycache__/pocket.cpython-312.pyc +0 -0
- engine/services/__pycache__/pocket.cpython-314.pyc +0 -0
- engine/services/__pycache__/state.cpython-312.pyc +0 -0
- engine/services/__pycache__/state.cpython-314.pyc +0 -0
- engine/services/__pycache__/story.cpython-312.pyc +0 -0
- engine/services/__pycache__/story.cpython-314.pyc +0 -0
- engine/services/paging.py +124 -0
- engine/services/story.py +4 -4
- engine/sub_agents/character/__pycache__/agent.cpython-312.pyc +0 -0
- engine/sub_agents/character/__pycache__/agent.cpython-314.pyc +0 -0
- engine/sub_agents/director/__pycache__/agent.cpython-312.pyc +0 -0
- engine/sub_agents/director/__pycache__/agent.cpython-314.pyc +0 -0
docs/ARCHITECTURE.md
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# sorac 카르밀라 — 전체 구조 명세서
|
| 2 |
+
|
| 3 |
+
> 이 문서 하나로 "무슨 파일이 어디 있고, 어떤 역할이며, 어떻게 맞물려 도는지"를 파악할 수 있게 쓴 명세서.
|
| 4 |
+
> 마지막 갱신: 2026-07-03 (MVP M0~M6 완료 시점)
|
| 5 |
+
|
| 6 |
+
---
|
| 7 |
+
|
| 8 |
+
## 0. 한 문장 요약
|
| 9 |
+
|
| 10 |
+
**비트카드(대표님이 쓴 서사 원고)를 YAML로 변환해 적재하고, 파이썬 상태머신이 "책 읽기"를,
|
| 11 |
+
google-adk LLM 파이프라인이 "카르밀라와의 대화"를 담당하며, 웹 리더가 그걸 화면에 그린다.**
|
| 12 |
+
|
| 13 |
+
```
|
| 14 |
+
[대표님 비트카드 MD] ──변환──▶ [content/ YAML] ──적재──▶ ┌─ StoryEngine (읽기: 결정적 코드)
|
| 15 |
+
└─ PocketService (대화: ADK LLM)
|
| 16 |
+
│
|
| 17 |
+
[web/index.html] ◀──HTTP── [router.py]
|
| 18 |
+
```
|
| 19 |
+
|
| 20 |
+
핵심 설계 원칙 (CLAUDE.md):
|
| 21 |
+
- **서사 진행은 코드가, 대사 생성만 LLM이** 한다 — 스토리가 산으로 가지 않게
|
| 22 |
+
- **숨은 상태(신뢰/의심/쇠약)는 유저에게 절대 숫자로 노출하지 않는다**
|
| 23 |
+
- **콘텐츠(YAML)와 코드의 분리** — 이야기 수정은 콘텐츠 쪽에서만
|
| 24 |
+
|
| 25 |
+
---
|
| 26 |
+
|
| 27 |
+
## 1. 루트 폴더 지도
|
| 28 |
+
|
| 29 |
+
| 경로 | 누가 만든 것 | 역할 |
|
| 30 |
+
|---|---|---|
|
| 31 |
+
| `CLAUDE.md` | 공동 | 개발 헌법. 아키텍처 규칙·금지사항. Claude Code가 매 세션 읽음 |
|
| 32 |
+
| `engine/` | Claude | **엔진 본체** (파이썬 패키지) — 아래 §3 |
|
| 33 |
+
| `content/` | 변환기 산출물 | **엔진이 읽는 서사 데이터** (YAML) — 아래 §2 |
|
| 34 |
+
| `web/` | Claude | 웹 리더 프론트 (단일 HTML) |
|
| 35 |
+
| `scripts/` | Claude | 개발용 스크립트 (변환기, CLI 플레이어) |
|
| 36 |
+
| `tests/` | Claude | 자동 테스트 18개 |
|
| 37 |
+
| `docs/` | Claude | 계획서·API 계약·본 명세서 |
|
| 38 |
+
| `logs/` | 런타임 생성 | 플레이 턴 로그 (연구 데이터, git 제외) |
|
| 39 |
+
| `data/` | 런타임 생성 | 플레이 세션 영속화 SQLite (`sessions.db`, git 제외) |
|
| 40 |
+
| `.env` | 유저 | OPENAI_API_KEY (git 제외 — 절대 커밋 금지) |
|
| 41 |
+
| `.venv/` | 설치 산출물 | 파이썬 가상환경 (google-adk 2.3.0 등) |
|
| 42 |
+
| `카르밀라/` | 대표님 | **콘텐츠 원천** — 비트카드 MD·원고·기획·이미지 |
|
| 43 |
+
| `reference/` | 대표님(프로토타입) | 비-ADK 개념검증 엔진. **참고용, 실행 안 함** |
|
| 44 |
+
| `requirements.txt` / `pyproject.toml` | Claude | 의존성 목록 / pytest 설정 |
|
| 45 |
+
|
| 46 |
+
---
|
| 47 |
+
|
| 48 |
+
## 2. content/ — 엔진이 읽는 서사 데이터 (3레이어)
|
| 49 |
+
|
| 50 |
+
**엔진의 유일한 서사 입력.** 원천은 `카르밀라/0701_카르밀라/비트카드/`의 MD 파일(대표님 소유)이고,
|
| 51 |
+
여기 있는 YAML은 `scripts/beatcard_convert.py`가 만든 **파생물**이다. 이야기를 고치려면
|
| 52 |
+
MD를 고치고 변환기를 다시 돌린다 (YAML 직접 수정 금지).
|
| 53 |
+
|
| 54 |
+
| 파일 | 레이어 | 내용 |
|
| 55 |
+
|---|---|---|
|
| 56 |
+
| `content/beatcards/E01.yaml` ~ `E06.yaml` | 내러티브 | **비트카드 61장.** 카드 = 화면 한 장. 지문 블록(prose/중앙대사/인물대사/무대지시), 선택지(라벨·결과산문·신뢰축·분기), next 링크, 태그 |
|
| 57 |
+
| `content/beatcards/R-회피사다리.yaml` | 내러티브 | 유저가 정체를 직격했을 때 카르밀라의 3단계 회피 대응 (재사용 카드) |
|
| 58 |
+
| `content/beatcards/R-약점반응.yaml` | 내러티브 | 성물·마늘 등 약점 언급 시 반응 원칙 |
|
| 59 |
+
| `content/chapters/carmilla_chapter1.yaml` | 챕터 메타 | 시작 카드(E01-01), 범위(E01~E06), **엔딩 3종 규칙**, 캐논 규칙, R카드 등록, 채팅 턴 상한(6) |
|
| 60 |
+
| `content/characters.yaml` | 정체성 | 인물 시트 3명(로라=플레이어, 카르밀라, 아버지). 성격·화법·지식경계 |
|
| 61 |
+
|
| 62 |
+
**카드 한 장의 구조 (예)**:
|
| 63 |
+
```yaml
|
| 64 |
+
- id: E06-09 # 카드 ID
|
| 65 |
+
title: 둘만의 비밀
|
| 66 |
+
canon: true # 원작 뼈대 (불변)
|
| 67 |
+
next: E06-10 # 다음 카드
|
| 68 |
+
axis: null # 카드 진입 시 신뢰축 이동 (trust/doubt/null)
|
| 69 |
+
frailty: false # 이 카드에서 쇠약도 +1 여부
|
| 70 |
+
narration: # 지문 블록들 (화면에 그대로 출력)
|
| 71 |
+
- {type: prose, text: "..."}
|
| 72 |
+
- {type: dialogue_center, text: "죽음은 오히려 축복일 수도 있어."}
|
| 73 |
+
- {type: dialogue_center, text: "...시작이야.", gate: trust_high} # 신뢰高 전용(심층 해금)
|
| 74 |
+
- {type: character_dialogue, speaker: 카르밀라, text: "...", anchor: true} # 채팅 변주 기준
|
| 75 |
+
choices:
|
| 76 |
+
- {label: 둘만의 비밀에 동조한다, axis: trust, result: "...", next: null}
|
| 77 |
+
- {label: 그래도 누군가에게 알려야..., axis: doubt, result: "...", next: null}
|
| 78 |
+
```
|
| 79 |
+
|
| 80 |
+
---
|
| 81 |
+
|
| 82 |
+
## 3. engine/ — 엔진 본체
|
| 83 |
+
|
| 84 |
+
### 3-1. 최상위 파일
|
| 85 |
+
|
| 86 |
+
| 파일 | 역할 |
|
| 87 |
+
|---|---|
|
| 88 |
+
| `model.py` | LLM 설정. 루트 `.env`를 로딩하고 LiteLLM으로 모델 초기화 (`LLM_MODEL`/`CHARACTER_MODEL` env로 교체 가능, 기본 openai/gpt-5.4-nano) |
|
| 89 |
+
| `agent.py` | **ADK 파이프라인 조립**: `pocket_session = Sequential[로더 → 가드 → 카르밀라 → 디렉터]`. `adk web`용 `root_agent`도 여기서 노출 |
|
| 90 |
+
| `app.py` | ADK `App`(이름+루트에이전트+플러그인) + `Runner`(세션 서비스) 생성 |
|
| 91 |
+
| `router.py` | **FastAPI 서버.** HTTP 엔드포인트(세션/advance/choose/pocket) + 웹 리더·이미지 서빙. 연출 이미지 매핑(`IMAGE_MAP`)도 여기 |
|
| 92 |
+
|
| 93 |
+
### 3-2. services/ — 비즈니스 로직 (게임 규칙의 심장)
|
| 94 |
+
|
| 95 |
+
| 파일 | 역할 |
|
| 96 |
+
|---|---|
|
| 97 |
+
| `story.py` | **읽기 상태머신.** 카드 전이(next), 선택지 반영, 심층 게이트 노출 판정, **엔딩 조건식 평가**(전용 파서 — eval 금지). 도달점 보장(preset_branch) 로직 포함 |
|
| 98 |
+
| `state.py` | **숨은 상태 3값.** 신뢰축 시소(trust/doubt, 이벤트당 ±12), 쇠약도(단조 증가만 허용), 앎(도입 1회 확립). 신뢰 레벨 환산(±12/±36 경계 → 5단계) — 이 레벨이 카르밀라의 "결"과 심층 해금을 결정 |
|
| 99 |
+
| `pocket.py` | **채팅 세션 관리.** 포켓 열기(카르밀라 카드만)/턴 실행/닫기(상태 커밋). **수렴 재검증**: LLM이 "끝내자"고 해도 코드가 검사 — 최소 3턴 체류, 비트 없는 종료 기각, 6턴 강제 수렴 |
|
| 100 |
+
| `paging.py` | **페이지 병합.** 여러 카드를 한 화면으로 — 결정적 파티션(선형 링크 구간만, 선택지·연출 컷·채팅 앵커·에피소드 경계에서 분리, `page_target_chars` 소프트 캡). 부수효과는 여전히 story.py가 카드당 1회 |
|
| 101 |
+
| `retrieval.py` | (2단계 스텁) GraphRAG 검색 자리. 스포일러 필터 강제 지점 |
|
| 102 |
+
|
| 103 |
+
### 3-3. sub_agents/ + agents/ — ADK 대화 파이프라인 (채팅 한 턴에 4명이 순서대로 일함)
|
| 104 |
+
|
| 105 |
+
```
|
| 106 |
+
유저 입력 → ① pocket_context_loader → ② input_guard → ③ carmilla → ④ director → 응답
|
| 107 |
+
```
|
| 108 |
+
|
| 109 |
+
| 순서 | 파일 | 타입 | 하는 일 |
|
| 110 |
+
|---|---|---|---|
|
| 111 |
+
| ① | `agents/pocket_context_loader.py` | 코드(BaseAgent) | 현재 카드의 지문·앵커대사·신뢰 레벨별 "결"을 세션에 적재. **캐릭터용 브리프와 디렉터용 노트를 분리** (스포일러 누수 방지) |
|
| 112 |
+
| ② | `agents/input_guard.py` | 코드(BaseAgent) | 유저 입력 검사: 정체 직격("흡혈귀지?") → R-회피사다리 단계 지시, 약점(십자가 등) → R-약점반응 지시, 메타 발화(AI/게임) → 세계관 복귀 지시 |
|
| 113 |
+
| ③ | `sub_agents/character/agent.py` | **LLM** | 카르밀라 응답 생성. instruction = `instructions/character_carmilla.md` + 인물시트 + 장면브리프 + 가드지시 |
|
| 114 |
+
| ④ | `sub_agents/director/agent.py` | **LLM** | 심판. 유저에게 안 보임. 도구 호출로만 기록: 신뢰축 이동/목표 비트 달성/수렴 선언 |
|
| 115 |
+
|
| 116 |
+
### 3-4. tools/state/ — 디렉터가 쓰는 도구 3종 (1파일 1함수)
|
| 117 |
+
|
| 118 |
+
| 파일 | 기능 |
|
| 119 |
+
|---|---|
|
| 120 |
+
| `update_trust.py` | 이번 턴 대화로 신뢰/의심 축을 ±12 이동 |
|
| 121 |
+
| `mark_beat.py` | 장면 목표 비트 달성 기록 |
|
| 122 |
+
| `finish_pocket.py` | 수렴 선언(escalate) — 단 **최종 결정은 pocket.py 코드가 재검증** |
|
| 123 |
+
|
| 124 |
+
### 3-5. 나머지
|
| 125 |
+
|
| 126 |
+
| 경로 | 역할 |
|
| 127 |
+
|---|---|
|
| 128 |
+
| `schemas/` | 콘텐츠 YAML의 Pydantic 스키마(beatcard/chapter/character) + **validate CLI** (`python -m engine.schemas.validate content/` — 스키마·링크 무결성·도달성 검사) |
|
| 129 |
+
| `repositories/content.py` | YAML → 메모리 적재. **스포일러 필터**(현재 카드 이후 조회 차단). 카드 순서/전체 수(진행도용). 콘텐츠 버전 스탬프(`content_version`) |
|
| 130 |
+
| `repositories/playlog.py` | 턴 로그 jsonl 저장 (`logs/playlog.jsonl`, 연구 데이터) |
|
| 131 |
+
| `repositories/play_session.py` | **세션 영속화** — 서사 위치+숨은 상태 스냅샷을 SQLite(`data/sessions.db`)에 매 변경 즉시 저장. 서버 재시작 후 복원(포켓 채팅은 비영속·폐기). 콘텐츠 버전 불일치 세션은 무효화 |
|
| 132 |
+
| `plugins/logging.py` | ADK 플러그인 — 채팅의 유저입력/응답/도구호출/상태변화를 자동 기록 |
|
| 133 |
+
| `instructions/` | LLM 프롬프트 원문 (카르밀라 인격 규칙, 디렉터 판정 절차). **말투·행동 규칙을 고치려면 여기** |
|
| 134 |
+
| `callbacks/` | (빈 스캐폴드) before_model 스포일러 최종 방어선 자리 — 2단계 |
|
| 135 |
+
|
| 136 |
+
---
|
| 137 |
+
|
| 138 |
+
## 4. web/ — 웹 리더 (프론트엔드)
|
| 139 |
+
|
| 140 |
+
`web/index.html` 단일 파일 (바닐라 JS, 빌드 없음). router.py가 서빙.
|
| 141 |
+
|
| 142 |
+
- **읽기**: 페이지 단위 페이징 — 한 페이지는 여러 카드를 병합(서버 `paging.py`, 목표 500자). 우측 클릭/→ = 다음, 좌측 클릭/← = 이전(읽기 전용 되돌아보기 — 선택 번복 불가). 연출 컷(이미지)은 자체 번호를 가진 독립 페이지 — 본문과 똑같이 넘기고 되돌아본다
|
| 143 |
+
- **이어 읽기**: 세션 ID를 `localStorage`에 보관 — 새로고침·서버 재시작 후에도 현재 페이지부터 재개 (표지 버튼이 "밤을 잇는다"로 바뀜). 엔딩에서 재시작하면 폐기
|
| 144 |
+
- **선택지**: 본문 아래 중앙 1열. 선택 결과 산문 표시 후 우측 클릭으로 진행
|
| 145 |
+
- **채팅**: 카르밀라 카드에서 "…말을 건다" → 하단 드로어. 수렴 시 여운(3.4초) 후 자동 복귀
|
| 146 |
+
- **연출**: 상단 진행 바(n/총페이지 = 카드 61 + 컷 5 = 66), 중요 장면 이미지 페이지, 표지
|
| 147 |
+
- 이미지 원본: `카르밀라/0630…/이미지/` (router의 `/assets`로 서빙, 매핑은 `IMAGE_MAP`)
|
| 148 |
+
|
| 149 |
+
---
|
| 150 |
+
|
| 151 |
+
## 5. scripts/ · tests/ · docs/
|
| 152 |
+
|
| 153 |
+
| 경로 | 역할 |
|
| 154 |
+
|---|---|
|
| 155 |
+
| `scripts/beatcard_convert.py` | **비트카드 MD → content/ YAML 변환기.** 선택지-결과 매칭, 분기 해석, 도달점 우회 방지. 콘텐츠 갱신 시 재실행 |
|
| 156 |
+
| `scripts/play.py` | 터미널 플레이어. `--policy trust/doubt/neutral --quiet`로 엔딩 3종 자동 검증 가능 |
|
| 157 |
+
| `tests/` (18개) | `test_story.py` 읽기/엔딩/스포일러 · `test_pocket.py` 채팅 파이프라인(가짜 LLM으로 결정적 검증) · `test_integration.py` 읽기→채팅→엔딩 전체 관통 · `fakes.py` 스크립트 LLM 테스트 더블 |
|
| 158 |
+
| `docs/plans/mvp.md` | 마일스톤 M0~M6 계획·상태·결정 로그 |
|
| 159 |
+
| `docs/contract/demo_api.md` | 웹 리더 ↔ 서버 HTTP API 계약 |
|
| 160 |
+
| `docs/ARCHITECTURE.md` | 본 문서 |
|
| 161 |
+
|
| 162 |
+
---
|
| 163 |
+
|
| 164 |
+
## 6. 런타임 흐름 (실제로 어떻게 도나)
|
| 165 |
+
|
| 166 |
+
### 읽기 턴 (LLM 없음, 0원)
|
| 167 |
+
```
|
| 168 |
+
[폰/브라우저] 우측 클릭
|
| 169 |
+
→ POST /api/session/{id}/advance (router.py)
|
| 170 |
+
→ StoryEngine.advance() (story.py)
|
| 171 |
+
카드 next 따라 이동 · 쇠약도/신뢰축 반영 · 심층 게이트 판정
|
| 172 |
+
→ 카드 JSON 응답 → 화면 렌더
|
| 173 |
+
```
|
| 174 |
+
|
| 175 |
+
### 선택 턴 (LLM 없음)
|
| 176 |
+
```
|
| 177 |
+
선택지 클릭 → POST /choose {index}
|
| 178 |
+
→ StoryEngine.choose(): 신뢰축 ±12 · 분기(next/preset) 결정 · 결과 산문 반환
|
| 179 |
+
```
|
| 180 |
+
|
| 181 |
+
### 채팅 턴 (LLM 2회 호출 — 카르밀라 1 + 디렉터 1)
|
| 182 |
+
```
|
| 183 |
+
"…말을 건다" → POST /pocket/open → PocketService.open() (카르밀라 카드 검증 + 상태 시딩)
|
| 184 |
+
메시지 전송 → POST /pocket/turn
|
| 185 |
+
→ ADK Runner: 로더 → 가드 → 카르밀라(LLM) → 디렉터(LLM+도구)
|
| 186 |
+
→ PocketService가 수렴 재검증 (3턴 미만 기각 / 6턴 강제 종료)
|
| 187 |
+
→ 수렴 시: 숨은 상태를 본편에 커밋하고 읽기로 복귀
|
| 188 |
+
```
|
| 189 |
+
|
| 190 |
+
### 엔딩 (LLM 없음)
|
| 191 |
+
```
|
| 192 |
+
E06 끝(→E07 참조) 도달 → story.py가 ending_rules 평가:
|
| 193 |
+
doubt ≥ 30 → ① 처단 (원작 캐논)
|
| 194 |
+
trust ≥ 60 & doubt<20 → ② 영원 (다크 로맨스)
|
| 195 |
+
그 외 → ③ 구원 (이별)
|
| 196 |
+
```
|
| 197 |
+
|
| 198 |
+
---
|
| 199 |
+
|
| 200 |
+
## 7. 실행 명령 모음
|
| 201 |
+
|
| 202 |
+
```bash
|
| 203 |
+
# 웹 리더 (폰 접속 허용)
|
| 204 |
+
.venv/bin/uvicorn engine.router:app --host 0.0.0.0 --port 8777
|
| 205 |
+
|
| 206 |
+
# 터미널 플레이 / 자동 엔딩 검증
|
| 207 |
+
.venv/bin/python scripts/play.py
|
| 208 |
+
.venv/bin/python scripts/play.py --policy trust --quiet
|
| 209 |
+
|
| 210 |
+
# 콘텐츠 갱신 (비트카드 MD 수정 후)
|
| 211 |
+
.venv/bin/python scripts/beatcard_convert.py "카르밀라/0701_카르밀라/비트카드" -o content/beatcards
|
| 212 |
+
.venv/bin/python -m engine.schemas.validate content/
|
| 213 |
+
|
| 214 |
+
# 테스트
|
| 215 |
+
.venv/bin/python -m pytest tests/ -q
|
| 216 |
+
```
|
| 217 |
+
|
| 218 |
+
---
|
| 219 |
+
|
| 220 |
+
## 8. "이걸 바꾸고 싶으면 어디를 보나" 치트시트
|
| 221 |
+
|
| 222 |
+
| 하고 싶은 것 | 보는 곳 |
|
| 223 |
+
|---|---|
|
| 224 |
+
| 이야기 본문·선택지 수정 | 비트카드 MD (`카르밀라/0701…/비트카드/`) → 변환기 재실행 |
|
| 225 |
+
| 카르밀라 말투·행동 규칙 | `engine/instructions/character_carmilla.md` |
|
| 226 |
+
| 대화 종료(수렴) 판정 기준 | `engine/instructions/director.md` + `engine/services/pocket.py` (MIN_TURNS 등) |
|
| 227 |
+
| 엔딩 조건 숫자 | `content/chapters/carmilla_chapter1.yaml` (ending_rules) |
|
| 228 |
+
| 신뢰축 이동량·레벨 경계 | `engine/services/state.py` (AXIS_DELTA, trust_level) |
|
| 229 |
+
| 채팅 턴 상한 | `content/chapters/carmilla_chapter1.yaml` (pocket_max_turns) |
|
| 230 |
+
| 페이지당 글 밀도 | `content/chapters/carmilla_chapter1.yaml`에 `page_target_chars` (미설정 시 500) |
|
| 231 |
+
| 연출 이미지 추가·교체 | `engine/router.py` (IMAGE_MAP) |
|
| 232 |
+
| 화면 디자인·인터랙션 | `web/index.html` |
|
| 233 |
+
| 세션 저장·복원 정책 (TTL, 버전 무효화) | `engine/repositories/play_session.py` + `engine/router.py` (`_save`/`_restore`) |
|
| 234 |
+
| 정체직격/약점 트리거 단어 | `engine/agents/input_guard.py` (TRIGGERS) |
|
| 235 |
+
| LLM 모델 교체 | `.env`에 `LLM_MODEL=...` / `CHARACTER_MODEL=...` |
|
docs/contract/demo_api.md
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 데모 API 계약 — 웹 리더 ↔ 엔진 (M6)
|
| 2 |
+
|
| 3 |
+
> 원칙: **숨은 상태(trust/doubt/쇠약도)는 어떤 응답에도 싣지 않는다** (보이지 않는 게임).
|
| 4 |
+
> 세션 영속화: 서사 위치 + 숨은 상태는 SQLite(`data/sessions.db`)에 매 변경 즉시 저장.
|
| 5 |
+
> 서버 재시작 후에도 `GET /api/session/{sid}`로 복원된다. **포켓(채팅) 중간 상태는 비영속** —
|
| 6 |
+
> 복원 시 포켓은 닫힌 상태로 시작하고, 진행 중이던 대화의 신뢰축 증분은 유실된다 (승인된 트레이드오프).
|
| 7 |
+
> 단일 프로세스 전제(인메모리 캐시 + ADK 인메모리 세션)는 유지.
|
| 8 |
+
|
| 9 |
+
## 공통 타입
|
| 10 |
+
|
| 11 |
+
```jsonc
|
| 12 |
+
// Step — 리딩 화면 한 장
|
| 13 |
+
{
|
| 14 |
+
"card": {"id": "E01-01", "title": "어둠으로의 초대", "episode": "E01",
|
| 15 |
+
"episode_title": "제1화: 어린 밤의 손님"},
|
| 16 |
+
"narration": [
|
| 17 |
+
{"type": "prose|dialogue_center|character_dialogue|stage_direction",
|
| 18 |
+
"text": "...", "speaker": ""} // speaker는 character_dialogue만
|
| 19 |
+
],
|
| 20 |
+
"choices": [{"label": "눈을 뜬다"}], // 없으면 []
|
| 21 |
+
"can_chat": true, // 개입 포켓(자유채팅) 가능 페이지
|
| 22 |
+
"image": "/assets/플래이 이미지/4.png", // 연출 컷 (없으면 null). 매핑: router IMAGE_MAP
|
| 23 |
+
"progress": {"index": 5, "total": 44}, // 읽기 진행도 (페이지 단위)
|
| 24 |
+
// 페이지 = 여러 카드 병합(services/paging.py, 결정적
|
| 25 |
+
// 파티션 · 밀도는 chapter.page_target_chars 기본 500자).
|
| 26 |
+
// card.id = 페이지 끝 카드(선택·채팅 기준),
|
| 27 |
+
// title/episode = 첫 카드 기준.
|
| 28 |
+
// 연출 컷은 단독 페이지 + 표시 2페이지(컷+본문) —
|
| 29 |
+
// image가 있으면 컷 페이지 번호 = index - 1
|
| 30 |
+
// (클라이언트는 컷을 먼저 렌더한 뒤 본문)
|
| 31 |
+
"ending": null // 챕터 종료 시에만 아래 Ending
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
// Ending
|
| 35 |
+
{"id": "ending_eternal", "label": "The Eternal — 동화/영원", "description": "..."}
|
| 36 |
+
|
| 37 |
+
// PocketTurn
|
| 38 |
+
{"reply": "…이상한 말을 하는구나, 로라.", "converged": false, "reason": null, "turn_no": 1}
|
| 39 |
+
```
|
| 40 |
+
|
| 41 |
+
## 엔드포인트
|
| 42 |
+
|
| 43 |
+
| 메서드/경로 | 요청 | 응답 | 비고 |
|
| 44 |
+
|---|---|---|---|
|
| 45 |
+
| `POST /api/session` | `{}` | `{"session_id", "step": Step}` | 새 플레이 시작 (E01-01) |
|
| 46 |
+
| `GET /api/session/{sid}` | — | `{"step": Step}` | 현재 위치 재조회 = **복원 진입점** (새로고침·서버 재시작). 404면 새 세션을 만들 것 |
|
| 47 |
+
| `POST /api/session/{sid}/advance` | `{}` | `{"step": Step}` | 선택지 없는 카드에서 다음 장 |
|
| 48 |
+
| `POST /api/session/{sid}/choose` | `{"index": 0}` | `{"picked": {"label","result"}, "step": Step}` | 선택 반영(축은 서버 내부) |
|
| 49 |
+
| `POST /api/session/{sid}/pocket/open` | `{}` | `{"opened": true}` | 현재 카드에서 채팅 시작. can_chat=false면 409 |
|
| 50 |
+
| `POST /api/session/{sid}/pocket/turn` | `{"text": "..."}` | `PocketTurn` | converged=true면 자동 close(커밋) 완료 상태 |
|
| 51 |
+
| `POST /api/session/{sid}/pocket/close` | `{}` | `{"closed": true}` | 유저가 먼저 대화를 접을 때 (커밋 포함) |
|
| 52 |
+
| `GET /api/meta` | — | `{"cover_image","title","subtitle"}` | 표지 등 프레젠테이션 상수 |
|
| 53 |
+
| `GET /assets/…` | — | 이미지 파일 | 연출 에셋 (대표님 산출물, 읽기 전용) |
|
| 54 |
+
|
| 55 |
+
오류: 존재하지 않는 세션 **또는 콘텐츠 갱신으로 무효화된 세션** 404 ·
|
| 56 |
+
상태에 안 맞는 호출(선택지 카드에서 advance 등) 409 · `{"detail": "..."}` (FastAPI 기본 포맷).
|
| 57 |
+
|
| 58 |
+
- 클라이언트는 `session_id`를 `localStorage`(`carmilla_sid`)에 보관하고, 기동 시 GET으로 복원을
|
| 59 |
+
시도한다. 404면 키를 버리고 새 세션. 복원 범위는 "현재 페이지부터" — 뒤로가기 히스토리는 세션 내 한정
|
| 60 |
+
- 콘텐츠(비트카드) 재변환으로 카드 ID/순서가 바뀌면 기존 세션은 서버가 무효화한다 (버전 스탬프)
|
| 61 |
+
|
| 62 |
+
## 흐름
|
| 63 |
+
|
| 64 |
+
```
|
| 65 |
+
POST /api/session ─→ step
|
| 66 |
+
├─ choices=[] ──── POST advance ──→ step (반복)
|
| 67 |
+
├─ choices=[...] ─ POST choose ──→ picked.result 표시 → step
|
| 68 |
+
│ └─ (선택 전) POST pocket/open → pocket/turn* → converged → 선택지로 복귀
|
| 69 |
+
└─ ending ─────── 엔딩 화면
|
| 70 |
+
```
|
| 71 |
+
|
| 72 |
+
- 포켓 수렴/턴 상한(6턴)·최소 체류(2턴)는 서버 코드가 강제. 클라이언트는 converged만 따른다
|
| 73 |
+
- 턴 로그는 서버가 `logs/playlog.jsonl`에 기록 (연구 데이터)
|
docs/plans/mvp.md
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 카르밀라 인터랙티브 노벨 — MVP 구현 계획
|
| 2 |
+
|
| 3 |
+
> 승인일: 2026-07-03 · 상태: **M0~M6 전체 완료 = MVP 완성** (2026-07-03, feature/mvp-m1-m4)
|
| 4 |
+
> 실행: `.venv/bin/uvicorn engine.router:app --port 8777` → http://localhost:8777 (웹 리더)
|
| 5 |
+
> `.venv/bin/python scripts/play.py` (CLI) · `adk web .` → engine (포켓 단독)
|
| 6 |
+
> LLM: 루트 `.env`의 OPENAI_API_KEY 자동 로딩. 실 LLM으로 회피사다리·심층해금·수렴 E2E 검증 완료
|
| 7 |
+
> follow-up: SequentialAgent → google.adk.workflow 마이그레이션 검토 · E06-09 유보(중립) 3안
|
| 8 |
+
> 선택지 미노출 · R-약점반응 구조화(현재 raw 주입) · 타 화자(아버지 등) 채팅 포켓 · 세션 영속화
|
| 9 |
+
> 원칙: CLAUDE.md 준수 (google-adk 공식 패턴, 서사 진행은 코드가·대사는 LLM이, 스포일러 필터 강제)
|
| 10 |
+
|
| 11 |
+
## MVP 정의
|
| 12 |
+
|
| 13 |
+
**E01~E06(챕터1)을 처음부터 끝까지 플레이 가능한 수직 슬라이스.**
|
| 14 |
+
|
| 15 |
+
- 카드 단위 읽기(L0, 캐논 고정) → 선택지/자유채팅 개입 포켓(L1) → 숨은 상태 누적 → 엔딩 3종 수렴(L2)
|
| 16 |
+
- 엔진: google-adk. 실행: CLI + `adk web`. 리딩 UI는 MVP 이후(M5)
|
| 17 |
+
- GraphRAG/Neo4j는 MVP 제외 — `kb_query`는 pre_kb 유지, repository 인터페이스만 준비
|
| 18 |
+
- `reference/`는 개념 검증 프로토타입. **참고만 하고 코드는 ADK로 새로 구축** (유저 확정)
|
| 19 |
+
|
| 20 |
+
## 데이터 모델 (A안 아키텍처 — reference v10에서 검증된 개념)
|
| 21 |
+
|
| 22 |
+
| 레이어 | 파일 | 내용 |
|
| 23 |
+
|---|---|---|
|
| 24 |
+
| 정체성 (불변) | `content/characters.yaml` | 인물 시트(ENOS), player=laura, 지식경계, kb_query 훅 |
|
| 25 |
+
| 챕터 메타 | `content/chapters/*.yaml` | entry/exit beats, beat_range, 엔딩 3종 규칙, R카드 등록 |
|
| 26 |
+
| 내러티브 | `content/beatcards/E0*/*.yaml` | 카드: narration 블록/choices/next/canon/복선 태그/연출 |
|
| 27 |
+
|
| 28 |
+
- **콘텐츠 원천(권위)**: `카르밀라/0701_카르밀라/비트카드/` MD (대표님 소유)
|
| 29 |
+
- 변환 YAML은 파생물: `scripts/beatcard_convert.py`(개발팀 소유)로 생성
|
| 30 |
+
- 숨은 상태 3값 (P0 4종 엔진 명세): **신뢰축**(시소) · **쇠약도**(단조 증가, 신뢰와 독립) · **앎**(유저, 도입 1회 확립 후 불변). 숫자/게이지 유저 비노출
|
| 31 |
+
- 엔딩 규칙: `doubt≥30→처단(캐논)` / `trust≥60∧doubt<20→영원` / `default→구원`
|
| 32 |
+
|
| 33 |
+
## 코드 vs LLM 경계
|
| 34 |
+
|
| 35 |
+
| 개념 | 구현 | 타입 |
|
| 36 |
+
|---|---|---|
|
| 37 |
+
| 카드 진행(next 링크, canon 읽기) | `services/story.py` 상태머신 | 결정적 코드 |
|
| 38 |
+
| 선택지 → 신뢰축 반영 | 선택 매핑 + state 커밋 | 결정적 코드 |
|
| 39 |
+
| 자유채팅 개입 포켓 | guard → character → director | ADK 파이프라인 |
|
| 40 |
+
| R-회피사다리/약점반응 | guard 규칙 + character instruction 주입 | BaseAgent + instruction |
|
| 41 |
+
| 숨은 상태 | session.state + FunctionTool → 포켓 종료 시 커밋 | ADK state |
|
| 42 |
+
| 수렴 판정 | director 구조화 출력 + `services/pocket.py` 코드 재검증 | LlmAgent + 코드 |
|
| 43 |
+
| 엔딩 분기 | ending_rules 조건식 평가 | 결정적 코드 |
|
| 44 |
+
| 스포일러 방어 | `beat_id ≤ 현재` 필터 + before_model 콜백 | 콜백 |
|
| 45 |
+
|
| 46 |
+
## 개입 포켓 파이프라인 (escalate 격리 규칙 적용)
|
| 47 |
+
|
| 48 |
+
```
|
| 49 |
+
pocket_session (SequentialAgent)
|
| 50 |
+
├── pocket_context_loader ← BaseAgent: 카드 브리프 + 신뢰 레벨 결 로드
|
| 51 |
+
├── turn_loop (LoopAgent max=카드별 상한)
|
| 52 |
+
│ ├── input_guard ← BaseAgent: 세계관 이탈/정체직격/약점 감지
|
| 53 |
+
│ ├── character ← LlmAgent: 카르밀라 (시트+화법6규칙+R카드+신뢰별 결)
|
| 54 |
+
│ └── director ← LlmAgent: 비트 달성/이탈/신뢰반영/수렴 → escalate
|
| 55 |
+
└── pocket_closer (LoopAgent max=1) ← 강제 수렴 내레이션 + 상태 커밋 → 다음 카드 복귀
|
| 56 |
+
```
|
| 57 |
+
|
| 58 |
+
## 마일스톤
|
| 59 |
+
|
| 60 |
+
- **M0 — 스캐폴드** (0.5일): git init(로컬만, 원격 푸시는 추후) · engine/ 뼈대 · 의존성 · CLAUDE.md 데이터 모델 섹션 갱신 · 본 문서
|
| 61 |
+
- **M1 — 콘텐츠 계약** (1일): 비트카드/챕터/캐릭터 Pydantic 스키마 + `python -m engine.schemas.validate content/` · 변환 스크립트 · next 링크 무결성 검사(고아/끊긴 체인). 검증 기준: 기존 61카드 통과
|
| 62 |
+
- **M2 — 결정적 리딩 루프** (1일): entry_beat부터 canon 페이징 · 선택지 신뢰축 반영 · 쇠약도 단조 증가 · **LLM 없이** CLI로 E01~E06 통독 + 엔딩 3종 도달 확인
|
| 63 |
+
- **M3 — ADK 개입 포켓** (2~3일, 핵심): 턴 파이프라인 · 카르밀라 instruction · director 구조화 판정(FunctionTool→state) · guard · `adk web`으로 포켓 1개(E06-09 권장) 집중 검증
|
| 64 |
+
- **M4 — 전체 관통** (1~2일): 리딩 루프 ↔ 포켓 연결 · E01→엔딩 전체 플레이 · 턴 로깅 플러그인 · 시연(CLI + adk web)
|
| 65 |
+
- **M5 — MVP 이후**: FastAPI router + 리딩 UI · GraphRAG(kb_query 활성화) · 평가 하네스 · style_critic 루프
|
| 66 |
+
|
| 67 |
+
## 폴더 구조
|
| 68 |
+
|
| 69 |
+
```
|
| 70 |
+
sorac/
|
| 71 |
+
├── engine/
|
| 72 |
+
│ ├── agent.py / app.py / model.py / router.py
|
| 73 |
+
│ ├── schemas/ beatcard.py · chapter.py · character.py · validate.py
|
| 74 |
+
│ ├── services/ story.py · pocket.py · state.py · retrieval.py(2단계 스텁)
|
| 75 |
+
│ ├── repositories/ content.py · playlog.py
|
| 76 |
+
│ ├── agents/ input_guard.py · pocket_context_loader.py
|
| 77 |
+
│ ├── sub_agents/ character/ · director/
|
| 78 |
+
│ ├── tools/ state/ (update_trust.py, mark_beat.py) · retrieval/(스텁)
|
| 79 |
+
│ ├── callbacks/ · plugins/logging.py · instructions/
|
| 80 |
+
├── content/ beatcards/ · chapters/ · characters.yaml (M1에서 적재)
|
| 81 |
+
├── scripts/ beatcard_convert.py
|
| 82 |
+
├── tests/
|
| 83 |
+
└── docs/plans/mvp.md (본 문서)
|
| 84 |
+
```
|
| 85 |
+
|
| 86 |
+
## 결정 로그
|
| 87 |
+
|
| 88 |
+
- 데모 프론트: 엔진 우선, UI 최소 (유저 확정)
|
| 89 |
+
- E01_구현계획.drawio 무시 (유저 지시) — 씬 설계 근거로 쓰지 않음
|
| 90 |
+
- 비트카드 MD가 콘텐츠 권위, reference 내 YAML은 파생물 (승인)
|
| 91 |
+
- CLAUDE.md "YAML 4종" 섹션 → A안 3레이어로 갱신 (승인)
|
| 92 |
+
- E07~E15: MD만 존재, MVP 범위 밖
|
docs/plans/persistence.md
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 인터스티셜 히스토리 + 세션 영속화 — 구현 계획
|
| 2 |
+
|
| 3 |
+
> 작성일: 2026-07-06 · 상태: **구현 완료** (2026-07-06, feature/persistence)
|
| 4 |
+
> 검증: pytest 26건 통과(신규 8건 포함) + 서버 재시작 e2e (E01-11 진행 → kill/재기동 →
|
| 5 |
+
> GET 복원 동일 카드 → advance 계속 진행 · 미존재 세션 404)
|
| 6 |
+
> 후속 수정 (2026-07-06, 유저 실플레이 피드백 2회): 오버레이 방식 자체를 폐기 —
|
| 7 |
+
> 연출 컷을 **자체 번호를 가진 독립 페이지**로 (예: 6 본문 → 7 컷 → 8 본문, total 66 = 61+5).
|
| 8 |
+
> 번호는 서버(router `_page_index`)가 권위, 컷 페이지 번호 = progress.index - 1.
|
| 9 |
+
> 프론트의 오버레이/pendingPage/클릭가드 로직 전부 제거 → 일반 페이지 흐름으로 단순화.
|
| 10 |
+
> Node DOM 스텁 시뮬레이션으로 정/역방향·좌클릭 통과 검증
|
| 11 |
+
> 선행 결정 (유저 확정):
|
| 12 |
+
> - 포켓(채팅) 중간 상태는 영속화하지 않는다 — 서버 재시작/복원 시 포켓은 버리고 읽기 화면으로
|
| 13 |
+
> - 복원 범위는 "현재 페이지부터 이어 읽기"만 — 뒤로가기 히스토리는 새로고침 시 포기
|
| 14 |
+
> 권장안 채택: SQLite + 스냅샷 + 매 변경 즉시 쓰기(write-through) + localStorage 세션 ID + 콘텐츠 버전 스탬프로 무효화
|
| 15 |
+
|
| 16 |
+
## 문제 정의
|
| 17 |
+
|
| 18 |
+
1. **인터스티셜 뒤로가기 버그**: 연출 컷이 히스토리의 페이지가 아니라 일회성 오버레이라서,
|
| 19 |
+
뒤로 넘기면 이미지가 건너뛰어지고 이전 스크립트가 나온다. 의도는 정방향과 동일하게
|
| 20 |
+
"스크립트 → 이미지 페이지 → 스크립트"가 역방향에서도 유지되는 것.
|
| 21 |
+
2. **영속화 부재**: 플레이 세션이 3층 모두 휘발성 —
|
| 22 |
+
`router._sessions`(파이썬 dict) · ADK `InMemorySessionService` · 브라우저 `SID`(JS 변수).
|
| 23 |
+
새로고침이면 세션 ID를 잃고, 서버 재시작이면 전 유저 진행이 사라진다.
|
| 24 |
+
CLAUDE.md 권위 원칙("현재 유저의 서사 위치 권위: DB `play_session.current_state_id`")이 예정한 DB가 아직 없다.
|
| 25 |
+
|
| 26 |
+
## 설계 핵심
|
| 27 |
+
|
| 28 |
+
### 복원의 함정 — `_enter()` 부수효과
|
| 29 |
+
|
| 30 |
+
`StoryEngine._enter()`는 진입 시 쇠약도 증가·축 적용·앎 확립을 수행한다.
|
| 31 |
+
복원을 `_enter(current_id)` 재호출로 구현하면 **상태가 이중 적용**된다.
|
| 32 |
+
따라서 복원은 부수효과 없는 전용 경로로: 필드를 직접 복원하고,
|
| 33 |
+
현재 카드의 StepResult는 렌더 전용 메서드로 재구성한다 (`_visible_narration` + `card.choices`는 순수 조회).
|
| 34 |
+
|
| 35 |
+
### 스냅샷 페이로드 (JSON 직렬화 — 전 필드 int/str/bool/list)
|
| 36 |
+
|
| 37 |
+
```json
|
| 38 |
+
{
|
| 39 |
+
"story": {"current_id": "E04-07", "visited": [...], "preset_branch": null, "ending_id": null},
|
| 40 |
+
"hidden": {"trust_score": 24, "doubt_score": 12, "frailty": 3,
|
| 41 |
+
"awareness_established": true, "axis_log": [...]}
|
| 42 |
+
}
|
| 43 |
+
```
|
| 44 |
+
|
| 45 |
+
- `ending_id`: 엔딩 도달 세션도 복원 대상 (새로고침 시 엔딩 화면 유지). 복원 시 `chapter.ending_rules`에서 id로 역조회
|
| 46 |
+
- 포켓 진행 중 저장분은 없다 — 포켓 안의 증분은 close 커밋 후에만 영속 상태에 반영 (기존 단일 경로 유지).
|
| 47 |
+
포켓 도중 서버가 죽으면 그 대화의 신뢰축 증분은 유실된다 (승인된 트레이드오프)
|
| 48 |
+
- 기존 `HiddenState.to_session()`은 포켓 전용(부분·키 상이)이므로 재사용하지 않고 스냅샷 메서드를 별도 추가
|
| 49 |
+
|
| 50 |
+
### 콘텐츠 버전 스탬프
|
| 51 |
+
|
| 52 |
+
- `ContentRepository`에 `content_version` 프로퍼티: `chapter.id` + 전역 순서의 카드 ID 목록을 sha256 해시
|
| 53 |
+
- 저장 시 스탬프, 복원 시 불일치면 **무효화**(404 → 프론트가 새 세션 시작).
|
| 54 |
+
카드 본문만 바뀐 경우는 잡지 못하지만(ID/순서 동일) 복원이 깨지지는 않으므로 데모 수용
|
| 55 |
+
|
| 56 |
+
### 저장 시점 (write-through)
|
| 57 |
+
|
| 58 |
+
영속 상태가 바뀌는 모든 지점에서 즉시 저장 — `POST /session`(생성) · `advance` · `choose` ·
|
| 59 |
+
`pocket/turn`(수렴 커밋 시) · `pocket/close`. `pocket/open`은 영속 상태 무변경이라 저장 없음.
|
| 60 |
+
|
| 61 |
+
### 동시성·수명
|
| 62 |
+
|
| 63 |
+
- uvicorn 워커 1개 전제 유지 (인메모리 캐시 + ADK InMemory 세션 때문). SQLite는 WAL 모드
|
| 64 |
+
- 같은 세션 동시 접근은 마지막 쓰기 승리 (데모 수용)
|
| 65 |
+
- 서버 기동 시 30일 미갱신 세션 purge
|
| 66 |
+
|
| 67 |
+
## 변경 파일 목록
|
| 68 |
+
|
| 69 |
+
### A. 인터스티셜 히스토리 (버그 수정 — 프론트 단독)
|
| 70 |
+
|
| 71 |
+
| 파일 | 변경 |
|
| 72 |
+
|---|---|
|
| 73 |
+
| `web/index.html` | 히스토리 항목을 2종으로 타입화: `{kind:'page', html, progress, ep}` · `{kind:'inter', image, caption, progress, ep}`. ① `showStep()`: `step.image` 있으면 inter 항목을 먼저 push하고 오버레이 표시, 오버레이 클릭 시 page 항목 push + 렌더 ② `go()`: 커서 이동을 공통 `renderEntry(cursor)`로 통일 — inter 항목이면 오버레이 표시, page 항목이면 HTML 복원 + 오버레이 닫기 ③ 브라우징 중 오버레이 클릭 = `go(+1)` (오버레이가 내비 존을 덮으므로; ��� 키는 계속 동작) ④ `choose()`의 스냅샷 갱신(`history[cursor].html`)은 page 항목에만 닿으므로 그대로 |
|
| 74 |
+
|
| 75 |
+
### B. 영속화
|
| 76 |
+
|
| 77 |
+
| 파일 | 변경 |
|
| 78 |
+
|---|---|
|
| 79 |
+
| `engine/services/state.py` | `HiddenState.snapshot() -> dict` / `HiddenState.from_snapshot(d)` 추가 (전 필드. `to_session()`과 별개 — 포켓 경로 불변) |
|
| 80 |
+
| `engine/services/story.py` | ① `snapshot() -> dict` (story 파트) ② `StoryEngine.restore(repo, snap) -> StoryEngine` 클래스메서드 — **부수효과 없이** 필드 직접 복원 ③ `current_step() -> StepResult` — 현재 카드/엔딩의 StepResult를 렌더 전용으로 재구성 |
|
| 81 |
+
| `engine/repositories/content.py` | `content_version` 프로퍼티 (sha256 스탬프) |
|
| 82 |
+
| `engine/repositories/play_session.py` | **신규.** stdlib `sqlite3`, WAL. 테이블 `play_session(sid TEXT PK, payload TEXT, content_version TEXT, updated_at TEXT)`. 메서드: `save(sid, payload, version)` / `load(sid, version) -> dict | None`(버전 불일치 시 None) / `purge(days=30)` |
|
| 83 |
+
| `engine/router.py` | ① `PlaySessionRepository(ROOT/"data"/"sessions.db")` 생성 + 기동 시 `purge()` ② `_sessions` dict는 캐시로 유지, `_get()` 미스 시 저장소에서 로드 → `StoryEngine.restore` + `current_step()`으로 PlaySession 재구성 (`pocket_sid=None` — 포켓 폐기) ③ 저장 헬퍼 `_save(sid, ps)`를 생성/advance/choose/pocket 수렴/close에 write-through |
|
| 84 |
+
| `web/index.html` | ① 기동 시 `localStorage.carmilla_sid` 있으면 `GET /api/session/{sid}` 시도 — 성공 시 표지 버튼이 "이어 읽기"로 동작(새 세션 생성 생략, 현재 페이지부터), 404면 sid 폐기 후 기존 흐름 ② 세션 생성 시 sid를 localStorage에 저장 ③ 엔딩 "다시, 처음의 밤으로" 클릭 시 sid 제거 후 reload |
|
| 85 |
+
| `.gitignore` | `data/` 추가 |
|
| 86 |
+
| `tests/test_persistence.py` | **신규.** ① 스냅샷→복원 라운드트립: 몇 카드 진행 후 복원했을 때 `current_id`/숨은 상태/진행도 동일 ② **복원이 쇠약도·축을 재적용하지 않음** (핵심 회귀) ③ 엔딩 세션 복원 ④ 저장소 save/load + 버전 불일치 시 None ⑤ 라우터 레벨: `_sessions` 캐시 비운 뒤(재시작 모사) GET으로 복원되고 포켓은 닫혀 있음 |
|
| 87 |
+
| `docs/contract/demo_api.md` | `GET /api/session/{sid}`가 복원 진입점임을 명시 (404 = 미존재 또는 콘텐츠 버전 불일치 → 새 세션 생성할 것). 포켓 비영속 명시 |
|
| 88 |
+
| `docs/plans/persistence.md` | 본 문서 |
|
| 89 |
+
|
| 90 |
+
### 변경하지 않는 것
|
| 91 |
+
|
| 92 |
+
- `engine/app.py` — ADK `InMemorySessionService` 유지 (포켓 비영속 결정에 따라 `SqliteSessionService` 불필요)
|
| 93 |
+
- `engine/services/pocket.py` — 포켓 커밋 단일 경로 불변
|
| 94 |
+
- `content/` — 무변경 (콘텐츠 커밋 금지 규칙)
|
| 95 |
+
|
| 96 |
+
## 작업 순서
|
| 97 |
+
|
| 98 |
+
1. **P1 — 인터스티셜 히스토리** (`web/index.html` 단독, 서버 무관) → 브라우저에서 정/역방향 수동 확인
|
| 99 |
+
2. **P2 — 스냅샷/복원 엔진** (`state.py`, `story.py`) + 단위 테스트 (부수효과 미재적용 회귀 포함)
|
| 100 |
+
3. **P3 — SQLite 저장소 + 라우터 write-through/복원** (`play_session.py`, `content.py`, `router.py`) + 라우터 테스트
|
| 101 |
+
4. **P4 — 프론트 이어 읽기** (`web/index.html` localStorage) → 새로고침·서버 재시작 후 이어 읽기 수동 확인
|
| 102 |
+
5. **P5 — 문서 갱신** (`demo_api.md`, ARCHITECTURE.md 치트시트 1줄)
|
| 103 |
+
|
| 104 |
+
검증: `pytest tests/ -q` 전건 통과 + 수동 시나리오 3종
|
| 105 |
+
(① 이미지 페이지 뒤로가기/앞으로가기 ② 새로고침 후 이어 읽기 ③ uvicorn 재시작 후 이어 읽기 — 포켓 중이었다면 읽기 화면으로).
|
| 106 |
+
|
| 107 |
+
## 리스크 / 한계 (승인된 트레이드오프 포함)
|
| 108 |
+
|
| 109 |
+
- 포켓 도중 서버 사망 시 그 대화의 신뢰축 증분 유실 (포켓 비영속 결정의 귀결)
|
| 110 |
+
- 새로고침 시 뒤로가기 히스토리·선택 결과 산문은 소실, 현재 페이지부터 (결정 ⑤)
|
| 111 |
+
- 카드 본문만 바뀐 콘텐츠 갱신은 버전 스탬프가 감지 못함 (ID/순서 동일 시) — 복원은 안 깨짐
|
| 112 |
+
- 멀티 워커/멀티 프로세스는 여전히 비지원 (ADK 세션·캐시가 인메모리)
|
engine/__pycache__/agent.cpython-312.pyc
CHANGED
|
Binary files a/engine/__pycache__/agent.cpython-312.pyc and b/engine/__pycache__/agent.cpython-312.pyc differ
|
|
|
engine/__pycache__/agent.cpython-314.pyc
CHANGED
|
Binary files a/engine/__pycache__/agent.cpython-314.pyc and b/engine/__pycache__/agent.cpython-314.pyc differ
|
|
|
engine/__pycache__/app.cpython-312.pyc
ADDED
|
Binary file (1.48 kB). View file
|
|
|
engine/__pycache__/app.cpython-314.pyc
CHANGED
|
Binary files a/engine/__pycache__/app.cpython-314.pyc and b/engine/__pycache__/app.cpython-314.pyc differ
|
|
|
engine/__pycache__/model.cpython-312.pyc
CHANGED
|
Binary files a/engine/__pycache__/model.cpython-312.pyc and b/engine/__pycache__/model.cpython-312.pyc differ
|
|
|
engine/__pycache__/model.cpython-314.pyc
CHANGED
|
Binary files a/engine/__pycache__/model.cpython-314.pyc and b/engine/__pycache__/model.cpython-314.pyc differ
|
|
|
engine/__pycache__/router.cpython-312.pyc
ADDED
|
Binary file (13.4 kB). View file
|
|
|
engine/__pycache__/router.cpython-314.pyc
CHANGED
|
Binary files a/engine/__pycache__/router.cpython-314.pyc and b/engine/__pycache__/router.cpython-314.pyc differ
|
|
|
engine/agents/__pycache__/input_guard.cpython-312.pyc
CHANGED
|
Binary files a/engine/agents/__pycache__/input_guard.cpython-312.pyc and b/engine/agents/__pycache__/input_guard.cpython-312.pyc differ
|
|
|
engine/agents/__pycache__/input_guard.cpython-314.pyc
CHANGED
|
Binary files a/engine/agents/__pycache__/input_guard.cpython-314.pyc and b/engine/agents/__pycache__/input_guard.cpython-314.pyc differ
|
|
|
engine/agents/__pycache__/pocket_context_loader.cpython-312.pyc
CHANGED
|
Binary files a/engine/agents/__pycache__/pocket_context_loader.cpython-312.pyc and b/engine/agents/__pycache__/pocket_context_loader.cpython-312.pyc differ
|
|
|
engine/agents/__pycache__/pocket_context_loader.cpython-314.pyc
CHANGED
|
Binary files a/engine/agents/__pycache__/pocket_context_loader.cpython-314.pyc and b/engine/agents/__pycache__/pocket_context_loader.cpython-314.pyc differ
|
|
|
engine/plugins/__pycache__/__init__.cpython-312.pyc
ADDED
|
Binary file (160 Bytes). View file
|
|
|
engine/plugins/__pycache__/logging.cpython-312.pyc
ADDED
|
Binary file (4.85 kB). View file
|
|
|
engine/plugins/__pycache__/logging.cpython-314.pyc
CHANGED
|
Binary files a/engine/plugins/__pycache__/logging.cpython-314.pyc and b/engine/plugins/__pycache__/logging.cpython-314.pyc differ
|
|
|
engine/repositories/__pycache__/content.cpython-312.pyc
CHANGED
|
Binary files a/engine/repositories/__pycache__/content.cpython-312.pyc and b/engine/repositories/__pycache__/content.cpython-312.pyc differ
|
|
|
engine/repositories/__pycache__/content.cpython-314.pyc
CHANGED
|
Binary files a/engine/repositories/__pycache__/content.cpython-314.pyc and b/engine/repositories/__pycache__/content.cpython-314.pyc differ
|
|
|
engine/repositories/__pycache__/play_session.cpython-312.pyc
ADDED
|
Binary file (4.81 kB). View file
|
|
|
engine/repositories/__pycache__/play_session.cpython-314.pyc
CHANGED
|
Binary files a/engine/repositories/__pycache__/play_session.cpython-314.pyc and b/engine/repositories/__pycache__/play_session.cpython-314.pyc differ
|
|
|
engine/repositories/__pycache__/playlog.cpython-312.pyc
ADDED
|
Binary file (1.64 kB). View file
|
|
|
engine/repositories/__pycache__/playlog.cpython-314.pyc
CHANGED
|
Binary files a/engine/repositories/__pycache__/playlog.cpython-314.pyc and b/engine/repositories/__pycache__/playlog.cpython-314.pyc differ
|
|
|
engine/repositories/content.py
CHANGED
|
@@ -75,6 +75,10 @@ class ContentRepository:
|
|
| 75 |
def has_card(self, card_id: str) -> bool:
|
| 76 |
return card_id in self._cards
|
| 77 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
def cards_upto(self, current_id: str) -> list[BeatCard]:
|
| 79 |
"""현재 시점까지의 카드 전부 (컨텍스트 주입용)."""
|
| 80 |
cut = self.order(current_id)
|
|
|
|
| 75 |
def has_card(self, card_id: str) -> bool:
|
| 76 |
return card_id in self._cards
|
| 77 |
|
| 78 |
+
def all_cards(self) -> list[BeatCard]:
|
| 79 |
+
"""전역 순서의 전체 카드 (페이지 파티션 등 정적 계산용 — 스포일러 필터 대상 아님)."""
|
| 80 |
+
return [self._cards[cid] for cid in sorted(self._order, key=self._order.get)]
|
| 81 |
+
|
| 82 |
def cards_upto(self, current_id: str) -> list[BeatCard]:
|
| 83 |
"""현재 시점까지의 카드 전부 (컨텍스트 주입용)."""
|
| 84 |
cut = self.order(current_id)
|
engine/router.py
CHANGED
|
@@ -6,7 +6,6 @@
|
|
| 6 |
|
| 7 |
from __future__ import annotations
|
| 8 |
|
| 9 |
-
import bisect
|
| 10 |
import uuid
|
| 11 |
from dataclasses import dataclass, field
|
| 12 |
from pathlib import Path
|
|
@@ -21,8 +20,9 @@ from engine.plugins.logging import TurnLoggingPlugin
|
|
| 21 |
from engine.repositories.content import ContentRepository
|
| 22 |
from engine.repositories.play_session import PlaySessionRepository
|
| 23 |
from engine.repositories.playlog import PlaylogRepository
|
|
|
|
| 24 |
from engine.services.pocket import PocketService
|
| 25 |
-
from engine.services.story import
|
| 26 |
|
| 27 |
ROOT = Path(__file__).resolve().parent.parent
|
| 28 |
|
|
@@ -43,19 +43,12 @@ app = FastAPI(title="carmilla-demo")
|
|
| 43 |
|
| 44 |
_repo = ContentRepository(ROOT / "content")
|
| 45 |
|
| 46 |
-
#
|
| 47 |
-
#
|
| 48 |
-
|
| 49 |
-
|
|
|
|
| 50 |
|
| 51 |
-
|
| 52 |
-
def _page_index(card_id: str) -> int:
|
| 53 |
-
"""카드 본문 페이지의 표시 번호 (앞선 이미지 페이지들 + 자기 이미지 페이지만큼 밀림).
|
| 54 |
-
이미지 카드의 이미지 페이지 번호는 이 값 - 1 (클라이언트가 유도)."""
|
| 55 |
-
order = _repo.order(card_id)
|
| 56 |
-
images_before = bisect.bisect_left(_IMAGE_ORDERS, order)
|
| 57 |
-
own_image = 1 if card_id in IMAGE_MAP else 0
|
| 58 |
-
return order + 1 + images_before + own_image
|
| 59 |
_pocket = PocketService(
|
| 60 |
_repo, plugins=[TurnLoggingPlugin(PlaylogRepository(ROOT / "logs" / "playlog.jsonl"))])
|
| 61 |
_playdb = PlaySessionRepository(ROOT / "data" / "sessions.db")
|
|
@@ -65,7 +58,7 @@ _playdb.purge(days=30)
|
|
| 65 |
@dataclass
|
| 66 |
class PlaySession:
|
| 67 |
engine: StoryEngine
|
| 68 |
-
last_step:
|
| 69 |
pocket_sid: Optional[str] = None
|
| 70 |
episode_titles: dict = field(default_factory=dict)
|
| 71 |
|
|
@@ -96,7 +89,7 @@ def _restore(sid: str) -> Optional[PlaySession]:
|
|
| 96 |
if payload is None:
|
| 97 |
return None
|
| 98 |
engine = StoryEngine.restore(_repo, payload)
|
| 99 |
-
ps = PlaySession(engine=engine, last_step=
|
| 100 |
episode_titles=_episode_titles())
|
| 101 |
_sessions[sid] = ps
|
| 102 |
return ps
|
|
@@ -108,23 +101,24 @@ def _save(sid: str, ps: PlaySession) -> None:
|
|
| 108 |
|
| 109 |
|
| 110 |
def _step_json(ps: PlaySession) -> dict:
|
| 111 |
-
|
| 112 |
-
if
|
| 113 |
return {"card": None, "narration": [], "choices": [], "can_chat": False,
|
| 114 |
"image": None,
|
| 115 |
-
"progress": {"index":
|
| 116 |
-
"ending": {"id":
|
| 117 |
-
"description":
|
| 118 |
-
|
| 119 |
return {
|
| 120 |
-
|
| 121 |
-
|
|
|
|
| 122 |
"narration": [{"type": b.type, "text": b.text, "speaker": b.speaker}
|
| 123 |
-
for b in
|
| 124 |
-
"choices": [{"label": c.label} for c in
|
| 125 |
-
"can_chat":
|
| 126 |
-
"image": IMAGE_MAP.get(
|
| 127 |
-
"progress": {"index":
|
| 128 |
"ending": None,
|
| 129 |
}
|
| 130 |
|
|
@@ -133,7 +127,7 @@ def _step_json(ps: PlaySession) -> dict:
|
|
| 133 |
@app.post("/api/session")
|
| 134 |
async def create_session():
|
| 135 |
engine = StoryEngine(_repo)
|
| 136 |
-
ps = PlaySession(engine=engine, last_step=engine.start(),
|
| 137 |
episode_titles=_episode_titles())
|
| 138 |
sid = uuid.uuid4().hex[:12]
|
| 139 |
_sessions[sid] = ps
|
|
@@ -160,7 +154,7 @@ async def advance(sid: str):
|
|
| 160 |
raise HTTPException(409, "포켓 대화 중 — 먼저 닫을 것")
|
| 161 |
if ps.last_step.ending or ps.last_step.choices:
|
| 162 |
raise HTTPException(409, "advance 불가 상태")
|
| 163 |
-
ps.last_step = ps.engine.advance()
|
| 164 |
_save(sid, ps)
|
| 165 |
return {"step": _step_json(ps)}
|
| 166 |
|
|
@@ -174,7 +168,8 @@ async def choose(sid: str, body: ChooseBody):
|
|
| 174 |
raise HTTPException(409, "선택 불가 상태")
|
| 175 |
if not 0 <= body.index < len(ps.last_step.choices):
|
| 176 |
raise HTTPException(422, "선택지 범위 밖")
|
| 177 |
-
picked,
|
|
|
|
| 178 |
_save(sid, ps)
|
| 179 |
return {"picked": {"label": picked.label, "result": picked.result},
|
| 180 |
"step": _step_json(ps)}
|
|
@@ -186,7 +181,7 @@ async def pocket_open(sid: str):
|
|
| 186 |
ps = _get(sid)
|
| 187 |
if ps.pocket_sid:
|
| 188 |
return {"opened": True}
|
| 189 |
-
if ps.last_step.ending or not
|
| 190 |
raise HTTPException(409, "이 카드는 자유채팅 포켓이 아님")
|
| 191 |
ps.pocket_sid = await _pocket.open(ps.engine.current_id, ps.engine.state)
|
| 192 |
return {"opened": True}
|
|
|
|
| 6 |
|
| 7 |
from __future__ import annotations
|
| 8 |
|
|
|
|
| 9 |
import uuid
|
| 10 |
from dataclasses import dataclass, field
|
| 11 |
from pathlib import Path
|
|
|
|
| 20 |
from engine.repositories.content import ContentRepository
|
| 21 |
from engine.repositories.play_session import PlaySessionRepository
|
| 22 |
from engine.repositories.playlog import PlaylogRepository
|
| 23 |
+
from engine.services.paging import Pager, PageView
|
| 24 |
from engine.services.pocket import PocketService
|
| 25 |
+
from engine.services.story import StoryEngine
|
| 26 |
|
| 27 |
ROOT = Path(__file__).resolve().parent.parent
|
| 28 |
|
|
|
|
| 43 |
|
| 44 |
_repo = ContentRepository(ROOT / "content")
|
| 45 |
|
| 46 |
+
# 페이지 병합 — 여러 카드를 한 화면으로 (밀도: chapter.page_target_chars, 기본 500자).
|
| 47 |
+
# 연출 컷·채팅 앵커는 단독 페이지, 컷은 표시 2페이지(컷+본문). 파티션은 결정적(경로 무관)
|
| 48 |
+
_CUT_IDS = {cid for cid in IMAGE_MAP if _repo.has_card(cid)}
|
| 49 |
+
_SOLO_IDS = _CUT_IDS | {c.id for c in _repo.all_cards() if c.can_chat}
|
| 50 |
+
_pager = Pager(_repo, solo_ids=_SOLO_IDS, cut_ids=_CUT_IDS)
|
| 51 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
_pocket = PocketService(
|
| 53 |
_repo, plugins=[TurnLoggingPlugin(PlaylogRepository(ROOT / "logs" / "playlog.jsonl"))])
|
| 54 |
_playdb = PlaySessionRepository(ROOT / "data" / "sessions.db")
|
|
|
|
| 58 |
@dataclass
|
| 59 |
class PlaySession:
|
| 60 |
engine: StoryEngine
|
| 61 |
+
last_step: PageView
|
| 62 |
pocket_sid: Optional[str] = None
|
| 63 |
episode_titles: dict = field(default_factory=dict)
|
| 64 |
|
|
|
|
| 89 |
if payload is None:
|
| 90 |
return None
|
| 91 |
engine = StoryEngine.restore(_repo, payload)
|
| 92 |
+
ps = PlaySession(engine=engine, last_step=_pager.render_current(engine),
|
| 93 |
episode_titles=_episode_titles())
|
| 94 |
_sessions[sid] = ps
|
| 95 |
return ps
|
|
|
|
| 101 |
|
| 102 |
|
| 103 |
def _step_json(ps: PlaySession) -> dict:
|
| 104 |
+
page = ps.last_step
|
| 105 |
+
if page.ending is not None:
|
| 106 |
return {"card": None, "narration": [], "choices": [], "can_chat": False,
|
| 107 |
"image": None,
|
| 108 |
+
"progress": {"index": _pager.display_total, "total": _pager.display_total},
|
| 109 |
+
"ending": {"id": page.ending.id, "label": page.ending.label,
|
| 110 |
+
"description": page.ending.description}}
|
| 111 |
+
first, last = page.first, page.last
|
| 112 |
return {
|
| 113 |
+
# id는 페이지 끝 카드(선택·채팅·진행 기준), 제목·에피소드·컷은 첫 카드 기준
|
| 114 |
+
"card": {"id": last.id, "title": first.title, "episode": first.episode_id,
|
| 115 |
+
"episode_title": ps.episode_titles.get(first.episode_id, first.episode_id)},
|
| 116 |
"narration": [{"type": b.type, "text": b.text, "speaker": b.speaker}
|
| 117 |
+
for b in page.narration],
|
| 118 |
+
"choices": [{"label": c.label} for c in page.choices],
|
| 119 |
+
"can_chat": page.can_chat,
|
| 120 |
+
"image": IMAGE_MAP.get(first.id),
|
| 121 |
+
"progress": {"index": _pager.display_index(last.id), "total": _pager.display_total},
|
| 122 |
"ending": None,
|
| 123 |
}
|
| 124 |
|
|
|
|
| 127 |
@app.post("/api/session")
|
| 128 |
async def create_session():
|
| 129 |
engine = StoryEngine(_repo)
|
| 130 |
+
ps = PlaySession(engine=engine, last_step=_pager.complete(engine, engine.start()),
|
| 131 |
episode_titles=_episode_titles())
|
| 132 |
sid = uuid.uuid4().hex[:12]
|
| 133 |
_sessions[sid] = ps
|
|
|
|
| 154 |
raise HTTPException(409, "포켓 대화 중 — 먼저 닫을 것")
|
| 155 |
if ps.last_step.ending or ps.last_step.choices:
|
| 156 |
raise HTTPException(409, "advance 불가 상태")
|
| 157 |
+
ps.last_step = _pager.complete(ps.engine, ps.engine.advance())
|
| 158 |
_save(sid, ps)
|
| 159 |
return {"step": _step_json(ps)}
|
| 160 |
|
|
|
|
| 168 |
raise HTTPException(409, "선택 불가 상태")
|
| 169 |
if not 0 <= body.index < len(ps.last_step.choices):
|
| 170 |
raise HTTPException(422, "선택지 범위 밖")
|
| 171 |
+
picked, step = ps.engine.choose(body.index)
|
| 172 |
+
ps.last_step = _pager.complete(ps.engine, step)
|
| 173 |
_save(sid, ps)
|
| 174 |
return {"picked": {"label": picked.label, "result": picked.result},
|
| 175 |
"step": _step_json(ps)}
|
|
|
|
| 181 |
ps = _get(sid)
|
| 182 |
if ps.pocket_sid:
|
| 183 |
return {"opened": True}
|
| 184 |
+
if ps.last_step.ending or not ps.last_step.can_chat:
|
| 185 |
raise HTTPException(409, "이 카드는 자유채팅 포켓이 아님")
|
| 186 |
ps.pocket_sid = await _pocket.open(ps.engine.current_id, ps.engine.state)
|
| 187 |
return {"opened": True}
|
engine/schemas/__pycache__/beatcard.cpython-312.pyc
CHANGED
|
Binary files a/engine/schemas/__pycache__/beatcard.cpython-312.pyc and b/engine/schemas/__pycache__/beatcard.cpython-312.pyc differ
|
|
|
engine/schemas/__pycache__/beatcard.cpython-314.pyc
CHANGED
|
Binary files a/engine/schemas/__pycache__/beatcard.cpython-314.pyc and b/engine/schemas/__pycache__/beatcard.cpython-314.pyc differ
|
|
|
engine/schemas/__pycache__/chapter.cpython-312.pyc
CHANGED
|
Binary files a/engine/schemas/__pycache__/chapter.cpython-312.pyc and b/engine/schemas/__pycache__/chapter.cpython-312.pyc differ
|
|
|
engine/schemas/__pycache__/chapter.cpython-314.pyc
CHANGED
|
Binary files a/engine/schemas/__pycache__/chapter.cpython-314.pyc and b/engine/schemas/__pycache__/chapter.cpython-314.pyc differ
|
|
|
engine/schemas/__pycache__/character.cpython-312.pyc
CHANGED
|
Binary files a/engine/schemas/__pycache__/character.cpython-312.pyc and b/engine/schemas/__pycache__/character.cpython-312.pyc differ
|
|
|
engine/schemas/__pycache__/character.cpython-314.pyc
CHANGED
|
Binary files a/engine/schemas/__pycache__/character.cpython-314.pyc and b/engine/schemas/__pycache__/character.cpython-314.pyc differ
|
|
|
engine/schemas/__pycache__/validate.cpython-312.pyc
CHANGED
|
Binary files a/engine/schemas/__pycache__/validate.cpython-312.pyc and b/engine/schemas/__pycache__/validate.cpython-312.pyc differ
|
|
|
engine/schemas/__pycache__/validate.cpython-314.pyc
CHANGED
|
Binary files a/engine/schemas/__pycache__/validate.cpython-314.pyc and b/engine/schemas/__pycache__/validate.cpython-314.pyc differ
|
|
|
engine/schemas/chapter.py
CHANGED
|
@@ -49,6 +49,7 @@ class Chapter(BaseModel):
|
|
| 49 |
canon_rules: list[str] = Field(default_factory=list)
|
| 50 |
r_cards: list[RCardRef] = Field(default_factory=list)
|
| 51 |
pocket_max_turns: int = 6 # 개입 포켓 자유채팅 턴 상한 (강제 수렴)
|
|
|
|
| 52 |
|
| 53 |
|
| 54 |
class ChapterFile(BaseModel):
|
|
|
|
| 49 |
canon_rules: list[str] = Field(default_factory=list)
|
| 50 |
r_cards: list[RCardRef] = Field(default_factory=list)
|
| 51 |
pocket_max_turns: int = 6 # 개입 포켓 자유채팅 턴 상한 (강제 수렴)
|
| 52 |
+
page_target_chars: int = 500 # 페이지 병합 목표 글자 수 (넘으면 다음 카드부터 새 페이지)
|
| 53 |
|
| 54 |
|
| 55 |
class ChapterFile(BaseModel):
|
engine/services/__pycache__/paging.cpython-312.pyc
ADDED
|
Binary file (8.09 kB). View file
|
|
|
engine/services/__pycache__/paging.cpython-314.pyc
ADDED
|
Binary file (9.47 kB). View file
|
|
|
engine/services/__pycache__/pocket.cpython-312.pyc
ADDED
|
Binary file (7.13 kB). View file
|
|
|
engine/services/__pycache__/pocket.cpython-314.pyc
CHANGED
|
Binary files a/engine/services/__pycache__/pocket.cpython-314.pyc and b/engine/services/__pycache__/pocket.cpython-314.pyc differ
|
|
|
engine/services/__pycache__/state.cpython-312.pyc
CHANGED
|
Binary files a/engine/services/__pycache__/state.cpython-312.pyc and b/engine/services/__pycache__/state.cpython-312.pyc differ
|
|
|
engine/services/__pycache__/state.cpython-314.pyc
CHANGED
|
Binary files a/engine/services/__pycache__/state.cpython-314.pyc and b/engine/services/__pycache__/state.cpython-314.pyc differ
|
|
|
engine/services/__pycache__/story.cpython-312.pyc
ADDED
|
Binary file (10.9 kB). View file
|
|
|
engine/services/__pycache__/story.cpython-314.pyc
CHANGED
|
Binary files a/engine/services/__pycache__/story.cpython-314.pyc and b/engine/services/__pycache__/story.cpython-314.pyc differ
|
|
|
engine/services/paging.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""페이지 병합 — 여러 비트카드를 한 화면(페이지)으로 묶는 결정적 파티션.
|
| 2 |
+
|
| 3 |
+
원칙:
|
| 4 |
+
- 서사 진행·숨은 상태의 권위는 StoryEngine이 유지한다. Pager는 engine.advance()를
|
| 5 |
+
페이지 경계까지 반복 호출해 지문을 병합할 뿐, 부수효과를 직접 만들지 않는다
|
| 6 |
+
(카드당 정확히 1회 — 이중/누락 적용 금지)
|
| 7 |
+
- 파티션은 콘텐츠(전역 카드 순서)의 순수 함수 — 유저 경로·상태와 무관해
|
| 8 |
+
페이지 총수(진행도 분모)가 안정적이고, 복원 시에도 같은 페이지가 재구성된다
|
| 9 |
+
- 병합은 선형 링크(card.next == 전역 다음 카드)로 이어진 구간에서만. 강제 분리:
|
| 10 |
+
· 선택지/분기(next_options) 카드 뒤 (결정 지점)
|
| 11 |
+
· solo 카드(연출 컷·채팅 앵커— 호출자가 지정) 앞뒤
|
| 12 |
+
· 에피소드 경계
|
| 13 |
+
- 소프트 캡: 누적 글자 수가 chapter.page_target_chars 이상이면 다음 카드부터 새 페이지
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
from dataclasses import dataclass, field
|
| 19 |
+
from typing import Optional
|
| 20 |
+
|
| 21 |
+
from engine.repositories.content import ContentRepository
|
| 22 |
+
from engine.schemas.beatcard import BeatCard, Choice, NarrationBlock
|
| 23 |
+
from engine.schemas.chapter import EndingRule
|
| 24 |
+
from engine.services.story import StepResult, StoryEngine
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
@dataclass
|
| 28 |
+
class PageView:
|
| 29 |
+
"""한 페이지의 렌더 출력 — 라우터가 그대로 JSON으로 만든다."""
|
| 30 |
+
first: Optional[BeatCard] = None # 페이지 제목·에피소드·연출 컷의 기준
|
| 31 |
+
last: Optional[BeatCard] = None # 선택지·채팅·진행 위치(current_id)의 기준
|
| 32 |
+
narration: list[NarrationBlock] = field(default_factory=list)
|
| 33 |
+
choices: list[Choice] = field(default_factory=list)
|
| 34 |
+
can_chat: bool = False
|
| 35 |
+
ending: Optional[EndingRule] = None
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
class Pager:
|
| 39 |
+
def __init__(self, repo: ContentRepository, solo_ids: set[str],
|
| 40 |
+
cut_ids: set[str], target_chars: int | None = None):
|
| 41 |
+
"""solo_ids: 단독 페이지 강제 카드(연출 컷+채팅 앵커) · cut_ids: 연출 컷(표시 2페이지)."""
|
| 42 |
+
self.repo = repo
|
| 43 |
+
self.solo = set(solo_ids)
|
| 44 |
+
self.cuts = set(cut_ids)
|
| 45 |
+
self.target = target_chars or repo.chapter.page_target_chars
|
| 46 |
+
self._build()
|
| 47 |
+
|
| 48 |
+
# ── 정적 파티션 ─────────────────────────────────────────
|
| 49 |
+
def _build(self) -> None:
|
| 50 |
+
cards = self.repo.all_cards()
|
| 51 |
+
parts: list[list[str]] = []
|
| 52 |
+
cur: list[str] = []
|
| 53 |
+
cur_chars = 0
|
| 54 |
+
prev: Optional[BeatCard] = None
|
| 55 |
+
for card in cards:
|
| 56 |
+
if cur and self._break_between(prev, card, cur_chars):
|
| 57 |
+
parts.append(cur)
|
| 58 |
+
cur, cur_chars = [], 0
|
| 59 |
+
cur.append(card.id)
|
| 60 |
+
cur_chars += sum(len(b.text) for b in card.narration)
|
| 61 |
+
prev = card
|
| 62 |
+
if cur:
|
| 63 |
+
parts.append(cur)
|
| 64 |
+
|
| 65 |
+
self.partitions = parts
|
| 66 |
+
self.part_of = {cid: i for i, part in enumerate(parts) for cid in part}
|
| 67 |
+
# 표시 번호: 파티션 = 1페이지, 연출 컷 파티션 = 2페이지(컷+본문)
|
| 68 |
+
self._cuts_before = []
|
| 69 |
+
seen = 0
|
| 70 |
+
for part in parts:
|
| 71 |
+
self._cuts_before.append(seen)
|
| 72 |
+
if part[0] in self.cuts:
|
| 73 |
+
seen += 1
|
| 74 |
+
self._total = len(parts) + seen
|
| 75 |
+
|
| 76 |
+
def _break_between(self, prev: BeatCard, card: BeatCard, cur_chars: int) -> bool:
|
| 77 |
+
if prev.choices or prev.next_options:
|
| 78 |
+
return True # 결정 지점 — 페이지는 선택지로 닫힌다
|
| 79 |
+
if prev.next != card.id:
|
| 80 |
+
return True # 비선형(분기 변형·점프·에피소드 참조) — 경로 안전을 위해 분리
|
| 81 |
+
if prev.id in self.solo or card.id in self.solo:
|
| 82 |
+
return True
|
| 83 |
+
if prev.episode_id != card.episode_id:
|
| 84 |
+
return True
|
| 85 |
+
return cur_chars >= self.target
|
| 86 |
+
|
| 87 |
+
# ── 표시 번호 (진행도) ──────────────────────────────────
|
| 88 |
+
@property
|
| 89 |
+
def display_total(self) -> int:
|
| 90 |
+
return self._total
|
| 91 |
+
|
| 92 |
+
def display_index(self, card_id: str) -> int:
|
| 93 |
+
"""카드가 속한 페이지의 본문 표시 번호. 컷 페이지 번호는 이 값 - 1 (기존 계약 유지)."""
|
| 94 |
+
p = self.part_of[card_id]
|
| 95 |
+
own_cut = 1 if self.partitions[p][0] in self.cuts else 0
|
| 96 |
+
return p + self._cuts_before[p] + own_cut + 1
|
| 97 |
+
|
| 98 |
+
# ── 런타임: 페이지 채우기 (부수효과는 engine.advance가 카드당 1회) ──
|
| 99 |
+
def complete(self, engine: StoryEngine, step: StepResult) -> PageView:
|
| 100 |
+
"""advance/choose/start 직후의 step(페이지 첫 카드)을 파티션 끝까지 채운다."""
|
| 101 |
+
if step.ending:
|
| 102 |
+
return PageView(ending=step.ending)
|
| 103 |
+
first = step.card
|
| 104 |
+
part = self.partitions[self.part_of[first.id]]
|
| 105 |
+
narration = list(step.narration)
|
| 106 |
+
while engine.current_id != part[-1]:
|
| 107 |
+
step = engine.advance()
|
| 108 |
+
if step.ending: # 파티션 내 링크는 챕터 안 — 방어적 처리
|
| 109 |
+
return PageView(ending=step.ending)
|
| 110 |
+
narration += step.narration
|
| 111 |
+
last = step.card
|
| 112 |
+
return PageView(first=first, last=last, narration=narration,
|
| 113 |
+
choices=list(last.choices), can_chat=last.can_chat)
|
| 114 |
+
|
| 115 |
+
# ── 런타임: 복원 렌더 (부수효과 없음) ───────────────────
|
| 116 |
+
def render_current(self, engine: StoryEngine) -> PageView:
|
| 117 |
+
"""현재 카드(항상 파티션 끝)가 속한 페이지 전체를 재구성 — 이어 읽기 화면."""
|
| 118 |
+
if engine.ending:
|
| 119 |
+
return PageView(ending=engine.ending)
|
| 120 |
+
part = self.partitions[self.part_of[engine.current_id]]
|
| 121 |
+
cards = [self.repo.get_card(cid) for cid in part]
|
| 122 |
+
narration = [b for c in cards for b in engine.visible_narration(c)]
|
| 123 |
+
return PageView(first=cards[0], last=cards[-1], narration=narration,
|
| 124 |
+
choices=list(cards[-1].choices), can_chat=cards[-1].can_chat)
|
engine/services/story.py
CHANGED
|
@@ -91,7 +91,7 @@ class StoryEngine:
|
|
| 91 |
if self.ending:
|
| 92 |
return StepResult(ending=self.ending)
|
| 93 |
card = self.repo.get_card(self.current_id)
|
| 94 |
-
return StepResult(card=card, narration=self.
|
| 95 |
choices=list(card.choices), is_pocket=card.is_pocket)
|
| 96 |
|
| 97 |
# ── 진행 ────────────────────────────────────────────────
|
|
@@ -114,7 +114,7 @@ class StoryEngine:
|
|
| 114 |
if card.axis and not any(ch.axis for ch in card.choices):
|
| 115 |
self.state.apply_axis(card.axis, source=card_id)
|
| 116 |
|
| 117 |
-
return StepResult(card=card, narration=self.
|
| 118 |
choices=list(card.choices), is_pocket=card.is_pocket)
|
| 119 |
|
| 120 |
def advance(self) -> StepResult:
|
|
@@ -164,7 +164,7 @@ class StoryEngine:
|
|
| 164 |
return StepResult(ending=rule)
|
| 165 |
raise RuntimeError("default 엔딩 규칙 없음 — 콘텐츠 결함")
|
| 166 |
|
| 167 |
-
# ── 지문 게이트 (엔진 D: 심층 해금) ──
|
| 168 |
-
def
|
| 169 |
return [b for b in card.narration
|
| 170 |
if b.gate == "all" or (b.gate == "trust_high" and self.state.deep_unlock)]
|
|
|
|
| 91 |
if self.ending:
|
| 92 |
return StepResult(ending=self.ending)
|
| 93 |
card = self.repo.get_card(self.current_id)
|
| 94 |
+
return StepResult(card=card, narration=self.visible_narration(card),
|
| 95 |
choices=list(card.choices), is_pocket=card.is_pocket)
|
| 96 |
|
| 97 |
# ── 진행 ────────────────────────────────────────────────
|
|
|
|
| 114 |
if card.axis and not any(ch.axis for ch in card.choices):
|
| 115 |
self.state.apply_axis(card.axis, source=card_id)
|
| 116 |
|
| 117 |
+
return StepResult(card=card, narration=self.visible_narration(card),
|
| 118 |
choices=list(card.choices), is_pocket=card.is_pocket)
|
| 119 |
|
| 120 |
def advance(self) -> StepResult:
|
|
|
|
| 164 |
return StepResult(ending=rule)
|
| 165 |
raise RuntimeError("default 엔딩 규칙 없음 — 콘텐츠 결함")
|
| 166 |
|
| 167 |
+
# ── 지문 게이트 (엔진 D: 심층 해금) — Pager 등 렌더 계층도 사용 ──
|
| 168 |
+
def visible_narration(self, card: BeatCard) -> list[NarrationBlock]:
|
| 169 |
return [b for b in card.narration
|
| 170 |
if b.gate == "all" or (b.gate == "trust_high" and self.state.deep_unlock)]
|
engine/sub_agents/character/__pycache__/agent.cpython-312.pyc
CHANGED
|
Binary files a/engine/sub_agents/character/__pycache__/agent.cpython-312.pyc and b/engine/sub_agents/character/__pycache__/agent.cpython-312.pyc differ
|
|
|
engine/sub_agents/character/__pycache__/agent.cpython-314.pyc
CHANGED
|
Binary files a/engine/sub_agents/character/__pycache__/agent.cpython-314.pyc and b/engine/sub_agents/character/__pycache__/agent.cpython-314.pyc differ
|
|
|
engine/sub_agents/director/__pycache__/agent.cpython-312.pyc
CHANGED
|
Binary files a/engine/sub_agents/director/__pycache__/agent.cpython-312.pyc and b/engine/sub_agents/director/__pycache__/agent.cpython-312.pyc differ
|
|
|
engine/sub_agents/director/__pycache__/agent.cpython-314.pyc
CHANGED
|
Binary files a/engine/sub_agents/director/__pycache__/agent.cpython-314.pyc and b/engine/sub_agents/director/__pycache__/agent.cpython-314.pyc differ
|
|
|