Spaces:
Running
Running
Upload 162 files
Browse files- CLAUDE.md +318 -0
- content/beatcards/E01.yaml +5 -0
- content/beatcards/E02.yaml +3 -0
- content/beatcards/E03.yaml +2 -0
- content/beatcards/E04.yaml +1 -0
- content/beatcards/E05.yaml +3 -0
- content/beatcards/E06.yaml +1 -0
- docs/ARCHITECTURE.md +10 -3
- docs/contract/beatcard_guide.md +41 -0
- docs/contract/demo_api.md +12 -4
- engine/__pycache__/router.cpython-312.pyc +0 -0
- engine/__pycache__/router.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/content.py +2 -1
- engine/router.py +1 -0
- engine/schemas/__pycache__/beatcard.cpython-312.pyc +0 -0
- engine/schemas/__pycache__/beatcard.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/beatcard.py +20 -0
- engine/schemas/validate.py +9 -0
- engine/services/__pycache__/paging.cpython-312.pyc +0 -0
- engine/services/__pycache__/paging.cpython-314.pyc +0 -0
- engine/services/paging.py +53 -17
- scripts/beatcard_convert.py +5 -0
- tests/__init__.py +0 -0
- tests/__pycache__/__init__.cpython-314.pyc +0 -0
- tests/__pycache__/fakes.cpython-314.pyc +0 -0
- tests/__pycache__/test_integration.cpython-314-pytest-9.1.1.pyc +0 -0
- tests/__pycache__/test_paging.cpython-314-pytest-9.1.1.pyc +0 -0
- tests/__pycache__/test_persistence.cpython-314-pytest-9.1.1.pyc +0 -0
- tests/__pycache__/test_pocket.cpython-314-pytest-9.1.1.pyc +0 -0
- tests/__pycache__/test_story.cpython-314-pytest-9.1.1.pyc +0 -0
- tests/fakes.py +44 -0
- tests/test_integration.py +88 -0
- tests/test_paging.py +200 -0
- tests/test_persistence.py +124 -0
- tests/test_pocket.py +137 -0
- tests/test_story.py +113 -0
- web/index.html +117 -47
CLAUDE.md
ADDED
|
@@ -0,0 +1,318 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# CLAUDE.md — Interactive Novel Engine Backend
|
| 2 |
+
|
| 3 |
+
## 프로젝트 개요
|
| 4 |
+
|
| 5 |
+
원작 고전 소설(카르밀라)을 온톨로지 기반으로 형식화하여 "책을 읽는 느낌 + 핵심 씬에서의 상호작용"을 제공하는 인터랙티브 노벨 엔진. Google ADK 기반 파이프라인이 유저 입력을 받아 가드 → 캐릭터 응답 → 디렉터(수렴 판단) 순으로 처리하고, 원작·온톨로지 검색은 GraphRAG 계층이 담당.
|
| 6 |
+
|
| 7 |
+
**최상위 가치: 원작 충실.** 원작의 톤과 세계관이 깨지는 순간 실패다. LLM의 자유도는 기능이 아니라 리스크로 취급한다.
|
| 8 |
+
|
| 9 |
+
## 아키텍처 3층 구조
|
| 10 |
+
|
| 11 |
+
```
|
| 12 |
+
[밑단] 온톨로지/상태 설계 문서 → YAML v1.0 (대표님 산출물) → 엔진이 파싱
|
| 13 |
+
[중간] GraphRAG: 온톨로지 YAML을 Neo4j에 적재 + 원문 발췌 벡터 검색
|
| 14 |
+
[앞단] ADK 에이전트 파이프라인 (guard → character → director)
|
| 15 |
+
```
|
| 16 |
+
|
| 17 |
+
- 온톨로지의 **내용**은 대표님 소유, YAML **스키마(형식)**는 개발팀 소유. 스키마 변경은 양쪽 합의로만.
|
| 18 |
+
- 그래프는 원문에서 LLM이 자동 추출하지 않는다. 큐레이션된 YAML을 적재하는 것이 유일한 경로.
|
| 19 |
+
|
| 20 |
+
## 작업 원칙
|
| 21 |
+
|
| 22 |
+
### 1. 논의 → 설계 → 계획 → 구현 → 리뷰
|
| 23 |
+
|
| 24 |
+
코드를 먼저 작성하지 않는다. 반드시 이 순서를 따른다:
|
| 25 |
+
|
| 26 |
+
1. **논의**: 요구사항을 정확히 이해하고 질문한다
|
| 27 |
+
2. **설계**: 2-3개 접근법을 비교하고 추천한다
|
| 28 |
+
3. **계획**: 구체적인 파일 목록과 변경 사항을 문서화한다
|
| 29 |
+
4. **구현**: 계획대로만 구현한다. 범위를 벗어나지 않는다
|
| 30 |
+
5. **리뷰**: 구현 결과를 spec과 대조하여 검증한다
|
| 31 |
+
6. **갱신**: 커밋 및 코드 구현이 완료되면 해당 부분을 꼼꼼하게 단계별로 메모리에 반영 및 갱신하세요
|
| 32 |
+
|
| 33 |
+
이 사이클을 충분히 반복한다. 급하다고 건너뛰지 않는다.
|
| 34 |
+
|
| 35 |
+
### 2. 기존 코드의 일관성을 지킨다
|
| 36 |
+
|
| 37 |
+
- 새 코드는 기존 패턴을 따른다. 더 나은 패턴이 있어도 혼자 바꾸지 않는다
|
| 38 |
+
- 폴더 구조, 네이밍, import 스타일을 기존 코드에서 먼저 확인한다
|
| 39 |
+
- "개선"이라는 이름으로 기존 컨벤션을 무시하지 않는다
|
| 40 |
+
|
| 41 |
+
### 3. Google ADK 공식 패턴을 따른다
|
| 42 |
+
|
| 43 |
+
- ADK가 제공하는 기능(LlmAgent, ParallelAgent, SequentialAgent, LoopAgent, AgentTool, FunctionTool)으로 해결한다
|
| 44 |
+
- Python으로 ADK를 우회하거나 대체하는 코드를 작성하지 않는다
|
| 45 |
+
- 프롬프트로 해결하려 하기 전에, 에이전트 구조(계층, 타입, 조합)로 해결할 수 있는지 먼저 검토한다
|
| 46 |
+
- **조금이라도 의심되면 바로 ADK 공식문서를 확인한다**: https://google.github.io/adk-docs/
|
| 47 |
+
|
| 48 |
+
### 4. 확인 없이 추측하지 않는다
|
| 49 |
+
|
| 50 |
+
- 파일을 수정하기 전에 반드시 읽는다
|
| 51 |
+
- ADK API를 사용할 때 기억에 의존하지 않고 문서나 소스를 확인한다
|
| 52 |
+
- Docker 로그, grep 결과 등 실제 증거를 기반으로 판단한다
|
| 53 |
+
- 원작 원문·온톨로지 내용을 기억으로 인용하지 않는다. 반드시 content/ 또는 그래프에서 조회한다
|
| 54 |
+
|
| 55 |
+
### 5. 서사 진행은 코드가, 대사는 LLM이
|
| 56 |
+
|
| 57 |
+
- 챕터/씬 전환, 분기 조건, 수렴 판정의 **최종 결정은 결정적 코드**(상태머신 + director 출력의 구조화된 검증)가 내린다
|
| 58 |
+
- LLM은 "지금 이 씬 안에서 이 캐릭터로 응답"하는 좁은 역할만 맡는다
|
| 59 |
+
- LLM 출력만 믿고 state를 전이시키지 않는다. 전이 조건은 반드시 코드에서 재검증한다
|
| 60 |
+
|
| 61 |
+
---
|
| 62 |
+
|
| 63 |
+
## 핵심 도메인 규칙
|
| 64 |
+
|
| 65 |
+
### 원작 충실 (Canon Fidelity)
|
| 66 |
+
|
| 67 |
+
- 캐릭터가 원작에 없는 사실을 단정하는 응답은 결함이다. 프롬프트에 주입된 온톨로지·원문 발췌 범위 안에서만 말하게 한다
|
| 68 |
+
- 유저가 세계관을 벗어난 행동을 하면 director가 세계관 내 화법으로 되돌린다. 메타 발화("저는 AI라서...")는 절대 금지
|
| 69 |
+
- 문체는 `content/style/` 문체연출 가이드가 기준. style_critic 루프가 검수한다
|
| 70 |
+
|
| 71 |
+
### 스포일러 스코프 (매우 중요)
|
| 72 |
+
|
| 73 |
+
- 모든 원문 청크, 그래프 노드/엣지, 온톨로지 항목에는 `state_id`(서사 진행 단위)가 붙는다
|
| 74 |
+
- **모든 검색은 `state_id <= 현재 state` 필터를 강제한다.** 필터 없는 검색 코드는 리뷰에서 반려
|
| 75 |
+
- 지식 상태: "state N 시점에 이 캐릭터가 아는 것"은 온톨로지의 지식 상태 항목이 권위. 캐릭터 프롬프트에는 해당 시점 지식만 주입한다
|
| 76 |
+
|
| 77 |
+
### 수렴 (Convergence)
|
| 78 |
+
|
| 79 |
+
- 씬마다 YAML에 정의된 목표 비트(goal beats)와 수렴 조건(exit condition)이 있다
|
| 80 |
+
- director는 매 턴 "목표 비트 달성 여부 / 이탈 정도 / 수렴 시점 도달 여부"를 구조화 출력으로 판단한다
|
| 81 |
+
- 최대 턴 수를 초과하면 director가 강제 수렴 내레이션으로 씬을 닫는다 (무한 체류 방지)
|
| 82 |
+
|
| 83 |
+
---
|
| 84 |
+
|
| 85 |
+
## 에이전트 폴더 규칙
|
| 86 |
+
|
| 87 |
+
```
|
| 88 |
+
에이전트 = 폴더 + agent.py ← 하위 에이전트��� 있는 계층형
|
| 89 |
+
하위 에이전트 = sub_agents/ 폴더 ← 부모-자식 계층
|
| 90 |
+
같은 뎁스 병렬 에이전트 = agents/ 파일 ← 자체 하위 없는 단순 에이전트 (가드 등)
|
| 91 |
+
도구 = tools/ 안에 1파일 = 1함수
|
| 92 |
+
instruction 10줄 초과 = instructions/ 폴더로 분리
|
| 93 |
+
팩토리 함수 = 조립만 담당 (클래스 정의/DB 헬퍼 포함 금지)
|
| 94 |
+
```
|
| 95 |
+
|
| 96 |
+
### sub_agents/ vs agents/ 구분
|
| 97 |
+
|
| 98 |
+
- `sub_agents/X/agent.py` 패턴은 **X가 추가로 하위 계층을 가질 때** 사용.
|
| 99 |
+
예: `sub_agents/director/sub_agents/style_critic/...`
|
| 100 |
+
- `agents/X.py` 패턴은 **X가 자체 하위 없이 단일 BaseAgent/LlmAgent로 끝날 때** 사용.
|
| 101 |
+
팩토리 함수(`create_X()`)와 클래스를 같은 파일에 모아둠.
|
| 102 |
+
예: `engine/agents/input_guard.py`, `spoiler_guard.py`, `scene_context_loader.py`
|
| 103 |
+
|
| 104 |
+
### 팩토리 파일 책임 분리
|
| 105 |
+
|
| 106 |
+
`factory.py`는 **조립만** 담당. 에이전트 클래스 정의·DB 헬퍼 함수는
|
| 107 |
+
`agents/*.py`나 `sub_agents/*/agent.py`에 위치. 팩토리는 그것들을 import해서
|
| 108 |
+
`SequentialAgent(sub_agents=[create_guard(scene_id), create_character(scene_id), ...])`
|
| 109 |
+
형태로 엮는 역할만.
|
| 110 |
+
|
| 111 |
+
### 엔진 구조
|
| 112 |
+
|
| 113 |
+
```
|
| 114 |
+
engine/
|
| 115 |
+
├── agent.py ← 루트 에이전트 (턴 파이프라인)
|
| 116 |
+
├── app.py ← ADK App + Runner + Plugin 등록
|
| 117 |
+
├── model.py ← LLM 모델 설정 (env에서 읽음)
|
| 118 |
+
├── router.py ← FastAPI 엔드포인트 (HTTP만 담당)
|
| 119 |
+
├── instructions/ ← instruction 텍스트 (캐릭터/디렉터/가드)
|
| 120 |
+
├── callbacks/ ← 에이전트별 콜백
|
| 121 |
+
├── plugins/ ← ADK Plugin (횡종단 관심사)
|
| 122 |
+
│ └── logging.py ← 턴 로깅 (입력/컨텍스트/응답/가드발동/수렴) — 연구 데이터 겸용
|
| 123 |
+
├── services/ ← 비즈니스 로직
|
| 124 |
+
│ ├── story.py ← 카드 진행 상태머신 (canon 읽기/next 전이/엔딩 판정)
|
| 125 |
+
│ ├── pocket.py ← 개입 포켓: ADK 세션 관리 + run_turn() + 수렴 코드 재검증
|
| 126 |
+
│ ├── state.py ← 숨은 상태 (신뢰축/쇠약도/앎) 커밋
|
| 127 |
+
│ └── retrieval.py ← 검색 오케스트레이션 (스포일러 필터 강제 지점, GraphRAG는 2단계)
|
| 128 |
+
├── repositories/ ← 데이터 접근
|
| 129 |
+
│ ├── ontology.py ← 온톨로지 조회 인터페이스 (구현: yaml_direct / neo4j)
|
| 130 |
+
│ ├── manuscript.py ← 원문 청크 + 벡터 검색
|
| 131 |
+
│ └── playlog.py ← 턴 로그 저장
|
| 132 |
+
├── tools/ ← 1파일 = 1도구함수
|
| 133 |
+
│ ├── retrieval/ ← query_ontology, search_excerpt
|
| 134 |
+
│ └── state/ ← set_flag, update_affinity, mark_beat
|
| 135 |
+
├── sub_agents/ ← 에이전트 트리
|
| 136 |
+
│ ├── character/ ← 캐릭터 응답 (인물 시트 + 씬 브리프 + 발췌 주입)
|
| 137 |
+
│ ├── director/ ← 비트 달성/이탈/수렴 판단 (구조화 출력)
|
| 138 |
+
│ └── style_critic/ ← LoopAgent (generator + critic) 문체 검수
|
| 139 |
+
├── schemas/ ← Pydantic: 콘텐츠 3레이어 스키마 + validate CLI
|
| 140 |
+
└── content/ ← (읽기 전용) 엔진의 유일한 서사 입력 — 원천은 비트카드 MD(대표님 소유)
|
| 141 |
+
├── beatcards/ ← 비트카드 YAML (scripts/beatcard_convert.py 산출물, E01~)
|
| 142 |
+
├── chapters/ ← 챕터 메타 (entry/exit beats, ending_rules, R카드 등록)
|
| 143 |
+
├── characters.yaml ← 인물 정체성 시트 (ENOS, 지식경계, kb_query 훅)
|
| 144 |
+
├── style/ ← 문체연출 가이드
|
| 145 |
+
└── narrative/ ← 서사/핸드오프 문서
|
| 146 |
+
```
|
| 147 |
+
|
| 148 |
+
### ADK App + Plugin 규칙
|
| 149 |
+
|
| 150 |
+
- `app.py`에서 `App` 객체 + `Runner`를 정의한다
|
| 151 |
+
- 횡종단 관심사(로깅, 모니터링 등)는 Plugin으로 분리한다 (`plugins/`)
|
| 152 |
+
- Runner는 `App` 기반으로 초기화한다 (`Runner(app=app, ...)`)
|
| 153 |
+
- agent를 Runner에 직접 전달하지 않는다 (레거시 패턴)
|
| 154 |
+
- 스캐폴드 파일(미구현 도구, 빈 콜백, 빈 instruction)은 삭제하지 않는다
|
| 155 |
+
- Claude Code가 ADK 패턴을 벗어나지 않도록 하는 가드레일 역할
|
| 156 |
+
- 호출 흐름: Agent → Tool(FunctionTool) → Service → Repository → 데이터(YAML/Neo4j/DB)
|
| 157 |
+
|
| 158 |
+
---
|
| 159 |
+
|
| 160 |
+
## 콘텐츠 파이프라인 (A안 아키텍처 — 2026-07-03 확정)
|
| 161 |
+
|
| 162 |
+
### 콘텐츠 3레이어
|
| 163 |
+
|
| 164 |
+
| 레이어 | 파일 | 내용 | 대응 스키마 |
|
| 165 |
+
|------|------|------|------|
|
| 166 |
+
| 정체성 (불변) | `content/characters.yaml` | 인물 시트(ENOS), player=laura, 지식경계, kb_query 훅 | `schemas/character.py` |
|
| 167 |
+
| 챕터 메타 | `content/chapters/*.yaml` | entry/exit beats, beat_range, 엔딩 규칙, R카드 등록 | `schemas/chapter.py` |
|
| 168 |
+
| 내러티브 | `content/beatcards/E##/*.yaml` | 카드: narration 블록/choices/next/canon/복선 태그/연출 | `schemas/beatcard.py` |
|
| 169 |
+
|
| 170 |
+
- **콘텐츠 원천(권위)은 비트카드 MD**(`카르밀라/.../비트카드/`, 대표님 소유). 변환 YAML은 파생물이며 `scripts/beatcard_convert.py`(개발팀 소유)로만 생성
|
| 171 |
+
- 콘텐츠 내용 수정은 개발팀이 하지 않는다. 스키마 위반 발견 시 검증 리포트로 대표님께 전달
|
| 172 |
+
- `python -m engine.schemas.validate content/` 가 통과하지 않는 YAML은 적재하지 않는다
|
| 173 |
+
- MVP의 서사 진행 단위는 비트카드 순서: 스포일러 필터의 `state_id`는 `beat_id ≤ 현재`로 해석한다
|
| 174 |
+
|
| 175 |
+
### 숨은 상태 모델 (P0 4종 엔진 명세 준거)
|
| 176 |
+
|
| 177 |
+
- **신뢰축**(의심↔신뢰 시소) · **쇠약도**(단조 증가, 신뢰와 독립 — 핵심 아이러니) · **앎**(유저, 도입 1회 확립 후 불변)
|
| 178 |
+
- 숫자·게이지·시스템 메시지를 유저에게 절대 노출하지 않는다. 상태는 내레이션 톤·카르밀라의 결·해금으로만 드러낸다
|
| 179 |
+
- 엔딩 판정은 챕터 메타의 `ending_rules` 조건식을 코드로 평가한다 (LLM 판정 금지)
|
| 180 |
+
|
| 181 |
+
### GraphRAG 계층 (2단계 — MVP 제외)
|
| 182 |
+
|
| 183 |
+
- MVP는 pre_kb 상태: `content/`의 `kb_query` 훅(`source: kb_query, status: pending`)은 채우지 않고 그대로 둔다
|
| 184 |
+
- MVP 콘텐츠 로더는 `repositories/content.py`(YAML 메모리 로드). 이후 GraphRAG 도입 시 `kb_query` 훅을 채우는 구현으로 교체. 상위 계층은 구현을 몰라야 한다 (ablation 실험 요건)
|
| 185 |
+
- Neo4j 적재 시 LLM 자동 추출 KG 빌더(SimpleKGPipeline 류)는 사용 금지 — 큐레이션 콘텐츠와 충돌
|
| 186 |
+
- 독자 상호작용 데이터의 그래프화도 2단계. MVP에서는 `playlog` 스키마 설계까지만
|
| 187 |
+
|
| 188 |
+
---
|
| 189 |
+
|
| 190 |
+
## ADK 핵심 규칙
|
| 191 |
+
|
| 192 |
+
### Instruction
|
| 193 |
+
|
| 194 |
+
| 종류 | 용도 | 주의 |
|
| 195 |
+
|------|------|------|
|
| 196 |
+
| `instruction` | 동적. `{var}`로 state 변수 치환 | `{}`는 state 변수로 파싱됨. 예시 텍스트에 `{}` 쓰지 말 것 |
|
| 197 |
+
| `global_instruction` | 전체 sub_agent에 전파 | 의도적으로 사용할 때만 채울 것 |
|
| 198 |
+
| `static_instruction` | 고정. 멀티모달 지원 | 캐릭터 시트처럼 씬 내 불변 컨텍스트에 검토 |
|
| 199 |
+
|
| 200 |
+
- 원작 발췌·온톨로지 컨텍스트를 instruction에 넣을 때 원문에 `{`, `}`가 포함될 수 있으므로 주입 전 이스케이프 처리를 거친다 (전처리 유틸 필수)
|
| 201 |
+
|
| 202 |
+
### Callback (8종)
|
| 203 |
+
|
| 204 |
+
`before_agent`, `after_agent`, `before_model`, `after_model`, `before_tool`, `after_tool`, `on_model_error`, `on_tool_error`
|
| 205 |
+
|
| 206 |
+
- `before_model`: 스포일러 필터 최종 방어선 (주입 컨텍스트의 state_id 검사)
|
| 207 |
+
- `after_model`: 턴 로깅 훅
|
| 208 |
+
|
| 209 |
+
### 에이전트 타입
|
| 210 |
+
|
| 211 |
+
| 타입 | 용도 | 예시 |
|
| 212 |
+
|------|------|------|
|
| 213 |
+
| `LlmAgent` | LLM이 판단/도구 호출 | character, director |
|
| 214 |
+
| `ParallelAgent` | 하위 에이전트 병렬 실행 | (필요 시) 다중 검색 |
|
| 215 |
+
| `SequentialAgent` | 하위 에이전트 순차 실행 | turn_pipeline (guard → character → director) |
|
| 216 |
+
| `LoopAgent` | 하위 에이전트 반복 (escalate로 탈출) | style_critic (generator + critic), 씬 내 턴 루프 |
|
| 217 |
+
| `BaseAgent` | LLM 없이 코드로 직접 처리 | input_guard(규칙 기반 1차), scene_context_loader, 상태 전이 검증 |
|
| 218 |
+
| `CustomAgent` | BaseAgent 확장. 조건 분기, 동적 에이전트 선택 | 씬 타입별 파이프라인 선택 |
|
| 219 |
+
|
| 220 |
+
### 절대 하지 말 것
|
| 221 |
+
|
| 222 |
+
- `output_schema`와 `tools` 동시 사용 — **Gemini 3.0만 지원**, 다른 모델에서는 안 됨. 필요하면 sub-agent로 출력 포맷팅을 분리할 것
|
| 223 |
+
- `mode="ANY"` 사용 (무한 루프 발생)
|
| 224 |
+
- instruction 안에 예시 텍스트로 `{한국어}` 사용 (state 변수로 파싱됨 → 에러)
|
| 225 |
+
- Python 코드로 ADK 워크플로우를 대체 (keyword matching, hardcoded fallback 등)
|
| 226 |
+
- `LlmAgent`의 `output_key`로 **구조화된 값을 기대** — LLM 텍스트만 저장됨. director의 수렴 판정처럼 구조화 값이 필요하면 `FunctionTool`이 `tool_context.state`에 직접 쓰거나 BaseAgent로 구현
|
| 227 |
+
- 검색/컨텍스트 주입 경로에서 `state_id` 필터를 생략 (스포일러 사고)
|
| 228 |
+
- LLM 판단만으로 씬/챕터 전이 (반드시 `services/scene.py`의 코드 검증을 거칠 것)
|
| 229 |
+
- 원작 원문을 프롬프트에 기억으로 재현 (반드시 repository 조회)
|
| 230 |
+
|
| 231 |
+
### Escalate scope 격리 (매우 중요)
|
| 232 |
+
|
| 233 |
+
ADK `LoopAgent`는 sub_agent의 `escalate=True` 이벤트를 **상위로 yield**한다. 즉
|
| 234 |
+
중첩 LoopAgent가 있으면 바깥 루프까지 함께 탈출한다(전 프로젝트 실측 확인). 이로 인해:
|
| 235 |
+
|
| 236 |
+
- **SequentialAgent는 escalate를 인식하지 않음**. escalate가 필요한 자리에 놓으려면
|
| 237 |
+
해당 범위만 `LoopAgent(max_iterations=1)`로 감쌀 것
|
| 238 |
+
- **내부 LoopAgent의 성공-escalate가 바깥을 오염시키면 안 되는 경우**엔 반드시
|
| 239 |
+
바깥을 SequentialAgent로 두고, escalate를 소화할 scope만 LoopAgent로
|
| 240 |
+
- 씬 턴 파이프라인 설계 시 이 규칙을 적용한 기준 구조:
|
| 241 |
+
```
|
| 242 |
+
scene_session (SequentialAgent)
|
| 243 |
+
├── scene_context_loader ← BaseAgent, YAML/그래프에서 씬 브리프 로드
|
| 244 |
+
├── turn_loop (LoopAgent max=씬별 max_turns) ← director의 수렴-escalate가 여기서 그침
|
| 245 |
+
│ ├── input_guard
|
| 246 |
+
│ ├── character
|
| 247 |
+
│ └── director ← 수렴 판정 시 escalate
|
| 248 |
+
└── scene_closer (LoopAgent max=1) ← 강제 수렴 내레이션 + 상태 전이 커밋
|
| 249 |
+
```
|
| 250 |
+
|
| 251 |
+
### 권위 소스 원칙
|
| 252 |
+
|
| 253 |
+
**"한 정보의 진실은 한 곳에만 있어야 한다."**
|
| 254 |
+
|
| 255 |
+
- 서사 내용(인물/관계/사건/지식상태) 권위: `content/yaml/` (적재된 Neo4j는 그 사본). 코드에 서사 내용 하드코딩 금지
|
| 256 |
+
- 씬 진행 상태 권위: `session.state` (씬 내) → 씬 종료 시 `scene.py`가 DB에 커밋 (영속). 두 곳을 동시에 갱신하는 코드 금지
|
| 257 |
+
- 현재 유저의 서사 위치 권위: DB `play_session.current_state_id`. 검색 필터는 이 값으로만
|
| 258 |
+
- 문체 기준 권위: `content/style/`. 프롬프트마다 문체 지시를 즉흥 작성하지 않는다
|
| 259 |
+
- 수렴 판정 권위: director의 구조화 출력 + `scene.py`의 코드 검증. LLM 텍스트 파싱으로 판정 금지
|
| 260 |
+
|
| 261 |
+
---
|
| 262 |
+
|
| 263 |
+
## LLM 모델
|
| 264 |
+
|
| 265 |
+
```python
|
| 266 |
+
# engine/model.py
|
| 267 |
+
from google.adk.models.lite_llm import LiteLlm
|
| 268 |
+
_MODEL_ID = os.environ.get("LLM_MODEL", "openai/gpt-5.4-nano")
|
| 269 |
+
LLM_MODEL = LiteLlm(model=_MODEL_ID)
|
| 270 |
+
```
|
| 271 |
+
|
| 272 |
+
- 로컬 테스트: 저비용 모델로 파이프라인 검증
|
| 273 |
+
- 캐릭터/문체 품질이 중요한 character, style_critic은 상위 모델을 별도 env로 분리 가능하게 (`CHARACTER_MODEL`)
|
| 274 |
+
- 모델 교체가 잦으므로 프롬프트에 특정 모델 의존 문법을 넣지 않는다
|
| 275 |
+
|
| 276 |
+
---
|
| 277 |
+
|
| 278 |
+
## 테스트 & 검증
|
| 279 |
+
|
| 280 |
+
```bash
|
| 281 |
+
# 단위 테스트
|
| 282 |
+
python3 -m pytest tests/ -v
|
| 283 |
+
|
| 284 |
+
# 콘텐츠 스키마 검증
|
| 285 |
+
python3 -m engine.schemas.validate content/
|
| 286 |
+
|
| 287 |
+
# 스포일러 필터 회귀 테스트 (state N에서 N+1 정보 누출 여부)
|
| 288 |
+
python3 -m pytest tests/test_spoiler_scope.py -v
|
| 289 |
+
|
| 290 |
+
# 평가 하네스 (LLM judge: 원작 충실도 / 문체 / 수렴 성공률)
|
| 291 |
+
python3 -m eval.run --scene <scene_id> --n 10
|
| 292 |
+
|
| 293 |
+
# Docker 빌드 확인
|
| 294 |
+
docker compose up -d --build backend
|
| 295 |
+
docker compose logs --tail=20 backend
|
| 296 |
+
```
|
| 297 |
+
|
| 298 |
+
- 프롬프트 변경은 평가 하네스 점수와 함께 PR에 첨부한다 (감이 아니라 숫자로)
|
| 299 |
+
- 턴 로그는 삭제하지 않는다 (연구 데이터)
|
| 300 |
+
|
| 301 |
+
---
|
| 302 |
+
|
| 303 |
+
## 커밋 규칙
|
| 304 |
+
|
| 305 |
+
- **main에 직접 푸시 금지.** 작업은 무조건 feature 브랜치에서 하고 PR로 올린다
|
| 306 |
+
- 파일 수정 전 반드시 Read로 현재 내용 확인
|
| 307 |
+
- 구조 변경 시 "복사 → 전환 → 삭제" 순서 (기존 기능 유지)
|
| 308 |
+
- Docker 빌드로 검증 후 커밋
|
| 309 |
+
- 비트카드 MD 원본과 `content/` 하위 콘텐츠 내용은 개발 커밋에서 수정하지 않는다 (콘텐츠 변경은 별도 PR + 대표님 승인. 변환 스크립트 재실행 산출물 갱신은 예외)
|
| 310 |
+
|
| 311 |
+
---
|
| 312 |
+
|
| 313 |
+
## 참고 링크
|
| 314 |
+
|
| 315 |
+
- ADK 공식문서: https://google.github.io/adk-docs/
|
| 316 |
+
- ADK GitHub: https://github.com/google/adk-python
|
| 317 |
+
- Neo4j GraphRAG Python: https://github.com/neo4j/neo4j-graphrag-python
|
| 318 |
+
- LiteLLM 문서: https://docs.litellm.ai/docs/providers
|
content/beatcards/E01.yaml
CHANGED
|
@@ -142,12 +142,14 @@ cards:
|
|
| 142 |
- type: prose
|
| 143 |
text: 성 안의 식구는 단출했다. 아버지와 나, 그리고 두 분의 부인. 한 분은 마담 페로동, 스위스 출신의 가정부이자 내 말동무였다. 둥글고 따뜻한 얼굴에 늘 바느질감을
|
| 144 |
든 분이었다.
|
|
|
|
| 145 |
- type: character_dialogue
|
| 146 |
speaker: 마담
|
| 147 |
text: 우리 아씨, 또 그렇게 멍하니 계시네. 이리 와 곁에 앉아요.
|
| 148 |
anchor: true
|
| 149 |
- type: prose
|
| 150 |
text: 또 한 분은 마드모아젤 드 라퐁텐, 프랑스계 가정교사였다. 책과 별자리와 옛 전설에 밝아, 달이 밝은 밤이면 알 수 없는 이야기로 나를 오싹하게 했다.
|
|
|
|
| 151 |
- type: character_dialogue
|
| 152 |
speaker: 마드모아젤
|
| 153 |
text: 달이 저렇게 밝은 밤은 조심해야 해요. 사람의 마음을 흔드는 기운이 있으니까.
|
|
@@ -374,12 +376,14 @@ cards:
|
|
| 374 |
narration:
|
| 375 |
- type: prose
|
| 376 |
text: 내 비명에 온 집안이 발칵 뒤집혔다. 보모와 하녀들이 등불을 들고 달려왔고, 곧 아버지와 두 부인도 잠옷 바람으로 들이닥쳤다.
|
|
|
|
| 377 |
- type: character_dialogue
|
| 378 |
speaker: 아버지
|
| 379 |
text: 무슨 일이냐, 로라. 무얼 보았어?
|
| 380 |
anchor: true
|
| 381 |
- type: prose
|
| 382 |
text: '*(유저는 본 것을 말할 수 있다 — 사람이 있었다고 우기든, 더듬더듬 설명하든.)* 어른들은 등불을 들고 침대 밑이며 옷장이며 온 방을 뒤졌다.'
|
|
|
|
| 383 |
- type: character_dialogue
|
| 384 |
speaker: 하인
|
| 385 |
text: 침대 밑엔… 아무것도 없습니다.
|
|
@@ -394,6 +398,7 @@ cards:
|
|
| 394 |
anchor: true
|
| 395 |
- type: prose
|
| 396 |
text: 아버지가 내 머리를 쓰다듬으며 부드럽게 말씀하셨다.
|
|
|
|
| 397 |
- type: character_dialogue
|
| 398 |
speaker: 아버지
|
| 399 |
text: 무서운 꿈을 꾼 게로구나, 우리 로라.
|
|
|
|
| 142 |
- type: prose
|
| 143 |
text: 성 안의 식구는 단출했다. 아버지와 나, 그리고 두 분의 부인. 한 분은 마담 페로동, 스위스 출신의 가정부이자 내 말동무였다. 둥글고 따뜻한 얼굴에 늘 바느질감을
|
| 144 |
든 분이었다.
|
| 145 |
+
role: lead_in
|
| 146 |
- type: character_dialogue
|
| 147 |
speaker: 마담
|
| 148 |
text: 우리 아씨, 또 그렇게 멍하니 계시네. 이리 와 곁에 앉아요.
|
| 149 |
anchor: true
|
| 150 |
- type: prose
|
| 151 |
text: 또 한 분은 마드모아젤 드 라퐁텐, 프랑스계 가정교사였다. 책과 별자리와 옛 전설에 밝아, 달이 밝은 밤이면 알 수 없는 이야기로 나를 오싹하게 했다.
|
| 152 |
+
role: lead_in
|
| 153 |
- type: character_dialogue
|
| 154 |
speaker: 마드모아젤
|
| 155 |
text: 달이 저렇게 밝은 밤은 조심해야 해요. 사람의 마음을 흔드는 기운이 있으니까.
|
|
|
|
| 376 |
narration:
|
| 377 |
- type: prose
|
| 378 |
text: 내 비명에 온 집안이 발칵 뒤집혔다. 보모와 하녀들이 등불을 들고 달려왔고, 곧 아버지와 두 부인도 잠옷 바람으로 들이닥쳤다.
|
| 379 |
+
role: lead_in
|
| 380 |
- type: character_dialogue
|
| 381 |
speaker: 아버지
|
| 382 |
text: 무슨 일이냐, 로라. 무얼 보았어?
|
| 383 |
anchor: true
|
| 384 |
- type: prose
|
| 385 |
text: '*(유저는 본 것을 말할 수 있다 — 사람이 있었다고 우기든, 더듬더듬 설명하든.)* 어른들은 등불을 들고 침대 밑이며 옷장이며 온 방을 뒤졌다.'
|
| 386 |
+
role: lead_in
|
| 387 |
- type: character_dialogue
|
| 388 |
speaker: 하인
|
| 389 |
text: 침대 밑엔… 아무것도 없습니다.
|
|
|
|
| 398 |
anchor: true
|
| 399 |
- type: prose
|
| 400 |
text: 아버지가 내 머리를 쓰다듬으며 부드럽게 말씀하셨다.
|
| 401 |
+
role: lead_in
|
| 402 |
- type: character_dialogue
|
| 403 |
speaker: 아버지
|
| 404 |
text: 무서운 꿈을 꾼 게로구나, 우리 로라.
|
content/beatcards/E02.yaml
CHANGED
|
@@ -116,6 +116,7 @@ cards:
|
|
| 116 |
narration:
|
| 117 |
- type: prose
|
| 118 |
text: 그런데 산책 도중, 아버지의 손에 한 통의 편지가 들려 있었다. 그날 낮 인편으로 도착한 장군의 편지였다. 아버지의 표정은 무겁게 가라앉아 있었다.
|
|
|
|
| 119 |
- type: character_dialogue
|
| 120 |
speaker: 아버지
|
| 121 |
text: 로라, 안타까운 소식이구나. 베르타 양은 오지 못한다." … "그 아이가 세상을 떠났다는구나. 알 수 없는 병에 걸려, 손쓸 새도 없이 시들어 갔다고 한다.
|
|
@@ -215,6 +216,7 @@ cards:
|
|
| 215 |
narration:
|
| 216 |
- type: prose
|
| 217 |
text: 노부인은 아버지에게 다가와 사고를 사죄하고는, 다급한 어조로 청했다.
|
|
|
|
| 218 |
- type: character_dialogue
|
| 219 |
speaker: 백작부인
|
| 220 |
text: 신사 양반, 부탁이 있습니다. 저는 한시도 지체할 수 없는 비밀스러운 여정 중입니다. 목숨이 걸린 일이며, 석 달 안에는 반드시 마쳐야 합니다. 한데 마차가 부서지고
|
|
@@ -254,6 +256,7 @@ cards:
|
|
| 254 |
- type: prose
|
| 255 |
text: 아버지는 잠시 곤혹스러워했으나, 쓰러진 아가씨의 가련한 모습과 노부인의 절박한 간청 앞에서 차마 거절하지 못하셨다. 본디 인정 많은 분이었고, 또래의 벗을 잃고 상심한
|
| 256 |
내 처지도 떠올리신 듯했다.
|
|
|
|
| 257 |
- type: character_dialogue
|
| 258 |
speaker: 아버지
|
| 259 |
text: 사정이 그러하다면, 기꺼이 따님을 모시겠습니다. 제 딸 로라가 좋은 벗이 되어 드릴 겁니다.
|
|
|
|
| 116 |
narration:
|
| 117 |
- type: prose
|
| 118 |
text: 그런데 산책 도중, 아버지의 손에 한 통의 편지가 들려 있었다. 그날 낮 인편으로 도착한 장군의 편지였다. 아버지의 표정은 무겁게 가라앉아 있었다.
|
| 119 |
+
role: lead_in
|
| 120 |
- type: character_dialogue
|
| 121 |
speaker: 아버지
|
| 122 |
text: 로라, 안타까운 소식이구나. 베르타 양은 오지 못한다." … "그 아이가 세상을 떠났다는구나. 알 수 없는 병에 걸려, 손쓸 새도 없이 시들어 갔다고 한다.
|
|
|
|
| 216 |
narration:
|
| 217 |
- type: prose
|
| 218 |
text: 노부인은 아버지에게 다가와 사고를 사죄하고는, 다급한 어조로 청했다.
|
| 219 |
+
role: lead_in
|
| 220 |
- type: character_dialogue
|
| 221 |
speaker: 백작부인
|
| 222 |
text: 신사 양반, 부탁이 있습니다. 저는 한시도 지체할 수 없는 비밀스러운 여정 중입니다. 목숨이 걸린 일이며, 석 달 안에는 반드시 마쳐야 합니다. 한데 마차가 부서지고
|
|
|
|
| 256 |
- type: prose
|
| 257 |
text: 아버지는 잠시 곤혹스러워했으나, 쓰러진 아가씨의 가련한 모습과 노부인의 절박한 간청 앞에서 차마 거절하지 못하셨다. 본디 인정 많은 분이었고, 또래의 벗을 잃고 상심한
|
| 258 |
내 처지도 떠올리신 듯했다.
|
| 259 |
+
role: lead_in
|
| 260 |
- type: character_dialogue
|
| 261 |
speaker: 아버지
|
| 262 |
text: 사정이 그러하다면, 기꺼이 따님을 모시겠습니다. 제 딸 로라가 좋은 벗이 되어 드릴 겁니다.
|
content/beatcards/E03.yaml
CHANGED
|
@@ -98,6 +98,7 @@ cards:
|
|
| 98 |
narration:
|
| 99 |
- type: prose
|
| 100 |
text: 우리는 거실 창가에 마주 앉았다. 나는 차마 입을 떼지 못하다가, 결국 마음속에 담아 두었던 말을 꺼내고 말았다.
|
|
|
|
| 101 |
- type: character_dialogue
|
| 102 |
speaker: 로라
|
| 103 |
text: 이상하게 들리실지 모르지만… 저는 당신을 전에 본 적이 있어요. 여섯 살 무렵, 한밤중에 제 방에 한 아름다운 여인이 나타나 저를 어르고 곁에 누웠지요. 모두들 꿈이라
|
|
@@ -105,6 +106,7 @@ cards:
|
|
| 105 |
anchor: true
|
| 106 |
- type: prose
|
| 107 |
text: 내 말이 끝나기 무섭게, 카르밀라의 두 눈이 놀라움으로 크게 떠졌다. 그녀는 한참을 말없이 나를 응시하더니, 떨리는 목소리로 입을 열었다.
|
|
|
|
| 108 |
- type: character_dialogue
|
| 109 |
speaker: 카르밀라
|
| 110 |
text: 세상에 이런 일이. 저 역시 똑같은 꿈을 꾸었어요. 어린 시절 어느 밤, 제 방에 한 사랑스러운 소녀가 나타났지요. 저는 그 아이를 끌어안고 함께 잠들었는데, 깨어
|
|
|
|
| 98 |
narration:
|
| 99 |
- type: prose
|
| 100 |
text: 우리는 거실 창가에 마주 앉았다. 나는 차마 입을 떼지 못하다가, 결국 마음속에 담아 두었던 말을 꺼내고 말았다.
|
| 101 |
+
role: lead_in
|
| 102 |
- type: character_dialogue
|
| 103 |
speaker: 로라
|
| 104 |
text: 이상하게 들리실지 모르지만… 저는 당신을 전에 본 적이 있어요. 여섯 살 무렵, 한밤중에 제 방에 한 아름다운 여인이 나타나 저를 어르고 곁에 누웠지요. 모두들 꿈이라
|
|
|
|
| 106 |
anchor: true
|
| 107 |
- type: prose
|
| 108 |
text: 내 말이 끝나기 무섭게, 카르밀라의 두 눈이 놀라움으로 크게 떠졌다. 그녀는 한참을 말없이 나를 응시하더니, 떨리는 목소리로 입을 열었다.
|
| 109 |
+
role: lead_in
|
| 110 |
- type: character_dialogue
|
| 111 |
speaker: 카르밀라
|
| 112 |
text: 세상에 이런 일이. 저 역시 똑같은 꿈을 꾸었어요. 어린 시절 어느 밤, 제 방에 한 사랑스러운 소녀가 나타났지요. 저는 그 아이를 끌어안고 함께 잠들었는데, 깨어
|
content/beatcards/E04.yaml
CHANGED
|
@@ -441,6 +441,7 @@ cards:
|
|
| 441 |
narration:
|
| 442 |
- type: prose
|
| 443 |
text: 그날 밤도 카르밀라는 격정적으로 나를 끌어안으며, 눈물 어린 눈으로 속삭였다.
|
|
|
|
| 444 |
- type: character_dialogue
|
| 445 |
speaker: 카르밀라
|
| 446 |
text: 로라, 너만은 절대로 그 행렬 속에 들어가게 하지 않을 거야. 너는 영원히 나와 함께 살 거란다. 죽음 따위가 너를 데려가도록 두지 않을 거야.
|
|
|
|
| 441 |
narration:
|
| 442 |
- type: prose
|
| 443 |
text: 그날 밤도 카르밀라는 격정적으로 나를 끌어안으며, 눈물 어린 눈으로 속삭였다.
|
| 444 |
+
role: lead_in
|
| 445 |
- type: character_dialogue
|
| 446 |
speaker: 카르밀라
|
| 447 |
text: 로라, 너만은 절대로 그 행렬 속에 들어가게 하지 않을 거야. 너는 영원히 나와 함께 살 거란다. 죽음 따위가 너를 데려가도록 두지 않을 거야.
|
content/beatcards/E05.yaml
CHANGED
|
@@ -156,6 +156,7 @@ cards:
|
|
| 156 |
narration:
|
| 157 |
- type: prose
|
| 158 |
text: 나는 반쯤 농담 삼아 말했다.
|
|
|
|
| 159 |
- type: character_dialogue
|
| 160 |
speaker: 로라
|
| 161 |
text: 카르밀라, 이 그림 당신과 너무 똑같지 않아요? 미르칼라 카른슈타인 백작부인이라니… 카른슈타인은 우리 어머니의 가문이에요. 어쩌면 이 부인이 내 먼 조상일지도. 그렇다면
|
|
@@ -203,6 +204,7 @@ cards:
|
|
| 203 |
narration:
|
| 204 |
- type: prose
|
| 205 |
text: 그녀는 그제야 나를 돌아보며, 나직이 말을 이었다.
|
|
|
|
| 206 |
- type: character_dialogue
|
| 207 |
speaker: 카르밀라
|
| 208 |
text: 그래, 어쩌면 우리는 아주 오래전부터 이어져 있었는지도 몰라. 우리가 이렇게 만난 것도, 그저 우연만은 아닐 거야, 로라.
|
|
@@ -354,6 +356,7 @@ cards:
|
|
| 354 |
narration:
|
| 355 |
- type: prose
|
| 356 |
text: 그날 밤늦게, 나는 아버지께 화랑의 초상화 이야기를 꺼냈다. 아버지는 서재의 오래된 가계도를 펼치며 말씀하셨다.
|
|
|
|
| 357 |
- type: character_dialogue
|
| 358 |
speaker: 아버지
|
| 359 |
text: 카른슈타인 가문은 한때 이 일대에서 가장 위세 높은 귀족이었단다. 그러나 백오십 년쯤 전 완전히 끊기고 말았지. 마지막 후손까지 모두 떠났고, 성과 마을은 버려졌다.
|
|
|
|
| 156 |
narration:
|
| 157 |
- type: prose
|
| 158 |
text: 나는 반쯤 농담 삼아 말했다.
|
| 159 |
+
role: lead_in
|
| 160 |
- type: character_dialogue
|
| 161 |
speaker: 로라
|
| 162 |
text: 카르밀라, 이 그림 당신과 너무 똑같지 않아요? 미르칼라 카른슈타인 백작부인이라니… 카른슈타인은 우리 어머니의 가문이에요. 어쩌면 이 부인이 내 먼 조상일지도. 그렇다면
|
|
|
|
| 204 |
narration:
|
| 205 |
- type: prose
|
| 206 |
text: 그녀는 그제야 나를 돌아보며, 나직이 말을 이었다.
|
| 207 |
+
role: lead_in
|
| 208 |
- type: character_dialogue
|
| 209 |
speaker: 카르밀라
|
| 210 |
text: 그래, 어쩌면 우리는 아주 오래전부터 이어져 있었는지도 몰라. 우리가 이렇게 만난 것도, 그저 우연만은 아닐 거야, 로라.
|
|
|
|
| 356 |
narration:
|
| 357 |
- type: prose
|
| 358 |
text: 그날 밤늦게, 나는 아버지께 화랑의 초상화 이야기를 꺼냈다. 아버지는 서재의 오래된 가계도를 펼치며 말씀하셨다.
|
| 359 |
+
role: lead_in
|
| 360 |
- type: character_dialogue
|
| 361 |
speaker: 아버지
|
| 362 |
text: 카른슈타인 가문은 한때 이 일대에서 가장 위세 높은 귀족이었단다. 그러나 백오십 년쯤 전 완전히 끊기고 말았지. 마지막 후손까지 모두 떠났고, 성과 마을은 버려졌다.
|
content/beatcards/E06.yaml
CHANGED
|
@@ -337,6 +337,7 @@ cards:
|
|
| 337 |
|
| 338 |
그러나 그 위안의 한복판에는 늘 한 점 서늘한 의문이 박혀 있었다. 어째서 그녀는 내가 쇠약해지는 것을 슬퍼하면서도, 단 한 번도 의사를 부르자거나 아버지께 알리자고 하지
|
| 339 |
않았을까.'
|
|
|
|
| 340 |
- type: character_dialogue
|
| 341 |
speaker: 카르밀라
|
| 342 |
text: 사람들은 이해 못 해. 너와 나 사이의 일을. 그러니 우리끼리만 간직하자.
|
|
|
|
| 337 |
|
| 338 |
그러나 그 위안의 한복판에는 늘 한 점 서늘한 의문이 박혀 있었다. 어째서 그녀는 내가 쇠약해지는 것을 슬퍼하면서도, 단 한 번도 의사를 부르자거나 아버지께 알리자고 하지
|
| 339 |
않았을까.'
|
| 340 |
+
role: lead_in
|
| 341 |
- type: character_dialogue
|
| 342 |
speaker: 카르밀라
|
| 343 |
text: 사람들은 이해 못 해. 너와 나 사이의 일을. 그러니 우리끼리만 간직하자.
|
docs/ARCHITECTURE.md
CHANGED
|
@@ -139,9 +139,12 @@ MD를 고치고 변환기를 다시 돌린다 (YAML 직접 수정 금지).
|
|
| 139 |
|
| 140 |
`web/index.html` 단일 파일 (바닐라 JS, 빌드 없음). router.py가 서빙.
|
| 141 |
|
| 142 |
-
- **읽기**:
|
|
|
|
| 143 |
- **이어 읽기**: 세션 ID를 `localStorage`에 보관 — 새로고침·서버 재시작 후에도 현재 페이지부터 재개 (표지 버튼이 "밤을 잇는다"로 바뀜). 엔딩에서 재시작하면 폐기
|
| 144 |
-
- **
|
|
|
|
|
|
|
| 145 |
- **채팅**: 카르밀라 카드에서 "…말을 건다" → 하단 드로어. 수렴 시 여운(3.4초) 후 자동 복귀
|
| 146 |
- **연출**: 상단 진행 바(n/총페이지 = 카드 61 + 컷 5 = 66), 중요 장면 이미지 페이지, 표지
|
| 147 |
- 이미지 원본: `카르밀라/0630…/이미지/` (router의 `/assets`로 서빙, 매핑은 `IMAGE_MAP`)
|
|
@@ -157,6 +160,7 @@ MD를 고치고 변환기를 다시 돌린다 (YAML 직접 수정 금지).
|
|
| 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 |
---
|
|
@@ -228,7 +232,10 @@ E06 끝(→E07 참조) 도달 → story.py가 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) |
|
|
|
|
| 139 |
|
| 140 |
`web/index.html` 단일 파일 (바닐라 JS, 빌드 없음). router.py가 서빙.
|
| 141 |
|
| 142 |
+
- **읽기**: 화면 단위 페이징 — 스크롤 없이 넘김으로만 읽는다. 서버 페이지(여러 카드 병합, `paging.py`가 run별 균형 분할 — 목표 500자, 병합 페이지 밴드 약 360~760자)를 프론트가 뷰포트 실측(`#page-measure`)으로 화면들로 분할(`buildScreens`), 같은 진행도 번호를 공유. 우측 클릭/→ = 다음, 좌측 클릭/← = 이전(읽기 전용 되돌아보기 — 선택 번복 불가). 연출 컷(이미지)은 자체 번호를 가진 독립 페이지
|
| 143 |
+
- **타이틀**: 에피소드 첫 페이지의 "제N화" 헤더만 표시 — 카드 소제목은 렌더하지 않는다(책의 흐름). 컷 캡션은 유지
|
| 144 |
- **이어 읽기**: 세션 ID를 `localStorage`에 보관 — 새로고침·서버 재시작 후에도 현재 페이지부터 재개 (표지 버튼이 "밤을 잇는다"로 바뀜). 엔딩에서 재시작하면 폐기
|
| 145 |
+
- **캐릭터 발화(하이브리드)**: 서사 속 `character_dialogue`는 컴팩트 char-line(좌 화자명 · 우 대사)으로 본문에 흡수 — 책의 흐름 우선. 유저·로라는 "나"로 표기. `dialogue_center`는 중앙 인용 스타일 유지
|
| 146 |
+
- **VN 스테이지**: 인터랙션 페이지(선택지·채팅)의 마지막 초상 발화만 스테이지(단독 화면) — 초상이 프레임(76vh)을 채우고 하단 패널에 장식 이름표·대사, 마지막 그룹이면 선택지·말걸기 버튼 내장. **발화 직전의 짧은 도입 prose(role=lead_in, 140자 이하 자동 유도)는 스테이지 상단에 얹힌다** — 한 문장 파편 화면 방지. 채팅 드로어가 열리면 초상 위에서 대화하는 구도 (참고: 플래이 이미지/7.png)
|
| 147 |
+
- **선택지**: `›` 마커 + 가는 구분선 리스트 (대화하듯). 선택 결과 산문 표시 후 우측 클릭으로 진행
|
| 148 |
- **채팅**: 카르밀라 카드에서 "…말을 건다" → 하단 드로어. 수렴 시 여운(3.4초) 후 자동 복귀
|
| 149 |
- **연출**: 상단 진행 바(n/총페이지 = 카드 61 + 컷 5 = 66), 중요 장면 이미지 페이지, 표지
|
| 150 |
- 이미지 원본: `카르밀라/0630…/이미지/` (router의 `/assets`로 서빙, 매핑은 `IMAGE_MAP`)
|
|
|
|
| 160 |
| `tests/` (18개) | `test_story.py` 읽기/엔딩/스포일러 · `test_pocket.py` 채팅 파이프라인(가짜 LLM으로 결정적 검증) · `test_integration.py` 읽기→채팅→엔딩 전체 관통 · `fakes.py` 스크립트 LLM 테스트 더블 |
|
| 161 |
| `docs/plans/mvp.md` | 마일스톤 M0~M6 계획·상태·결정 로그 |
|
| 162 |
| `docs/contract/demo_api.md` | 웹 리더 ↔ 서버 HTTP API 계약 |
|
| 163 |
+
| `docs/contract/beatcard_guide.md` | 비트카드 작성 가이드 (권장 분량·3막 패턴·리드인 규칙 — E07~ 집필 참고, validate 경고와 연동) |
|
| 164 |
| `docs/ARCHITECTURE.md` | 본 문서 |
|
| 165 |
|
| 166 |
---
|
|
|
|
| 232 |
| 신뢰축 이동량·레벨 경계 | `engine/services/state.py` (AXIS_DELTA, trust_level) |
|
| 233 |
| 채팅 턴 상한 | `content/chapters/carmilla_chapter1.yaml` (pocket_max_turns) |
|
| 234 |
| 페이지당 글 밀도 | `content/chapters/carmilla_chapter1.yaml`에 `page_target_chars` (미설정 시 500) |
|
| 235 |
+
| 연출 이미지 추가·교체 | `engine/router.py` (IMAGE_MAP) + `scripts/sync_assets.py` 재실행 (원천: 카르밀라/0701_카르밀라/이미지/) |
|
| 236 |
+
| 장소별 배경 추가·교체 | `engine/router.py` (BACKGROUND_MAP — 카드 `#장소/` 태그 기준) + sync_assets |
|
| 237 |
+
| 화자 초상 추가·교체 (VN 발화) | `engine/router.py` (SPEAKER_PORTRAIT) + sync_assets |
|
| 238 |
+
| 인트로 시퀀스(작품·인물 소개) 교체 | `engine/router.py` (INTRO_IMAGES) |
|
| 239 |
| 화면 디자인·인터랙션 | `web/index.html` |
|
| 240 |
| 세션 저장·복원 정책 (TTL, 버전 무효화) | `engine/repositories/play_session.py` + `engine/router.py` (`_save`/`_restore`) |
|
| 241 |
| 정체직격/약점 트리거 단어 | `engine/agents/input_guard.py` (TRIGGERS) |
|
docs/contract/beatcard_guide.md
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 비트카드 작성 가이드 — 화면이 되는 글
|
| 2 |
+
|
| 3 |
+
> 대상: 비트카드 MD 작성(대표님) · E07~E15 집필 참고.
|
| 4 |
+
> 배경: 엔진은 카드를 "화면"으로 만든다. 카드의 글자 수·블록 구성이 그대로
|
| 5 |
+
> 독자의 페이지 경험이 된다. 검증은 `python -m engine.schemas.validate content/`가
|
| 6 |
+
> 경고 리포트로 알려준다 (실패 아님 — 판단은 대표님 몫).
|
| 7 |
+
|
| 8 |
+
## 1. 카드 한 장 = 의미 있는 화면 하나
|
| 9 |
+
|
| 10 |
+
- **권장 지문 분량: 카드당 200~700자.**
|
| 11 |
+
- 200자 미만 → 화면이 빈약해진다 (validate가 경고). 예: E05-05(96자), E06-10C(154자)
|
| 12 |
+
- 700자 초과 → 엔진이 알아서 나누지만, 700자 안쪽이면 의도한 단위로 보여진다
|
| 13 |
+
- 선택지·채팅(앵커)·연출 컷 카드는 **항상 단독 화면**이 된다 — 이 카드들이 특히
|
| 14 |
+
분량·구성의 영향을 크게 받는다
|
| 15 |
+
|
| 16 |
+
## 2. 인터랙션 카드의 3막 패턴 (구조가 연출이 된다)
|
| 17 |
+
|
| 18 |
+
채팅·선택지 카드는 대개 이런 구조이고, 엔진이 이를 화면 연출로 해석한다:
|
| 19 |
+
|
| 20 |
+
```
|
| 21 |
+
[리드인] 짧은 도입 산문 (140자 이하) → 초상 화면 상단에 얹힘
|
| 22 |
+
[앵커] 캐릭터 발화 (LLM 변주 기준) → 초상 스테이지의 대사
|
| 23 |
+
[여파] 반응 산문 + 선택지 → 다음 화면, 선택지와 함께
|
| 24 |
+
```
|
| 25 |
+
|
| 26 |
+
- **발화 직전의 140자 이하 산문은 자동으로 리드인**으로 처리된다
|
| 27 |
+
(엔진 유도 규칙 — YAML `role: lead_in`으로 명시 지정도 가능)
|
| 28 |
+
- 리드인이 140자를 넘으면 독립 문단으로 취급된다 — 도입을 이미지에 얹히게
|
| 29 |
+
하려면 짧게, 별도 문단으로 읽히게 하려면 길게
|
| 30 |
+
|
| 31 |
+
## 3. 화자 표기
|
| 32 |
+
|
| 33 |
+
- 지문 속 인물 대사는 `인물명: "대사"` 형식이 아닌 **발화 블록**(변환기가
|
| 34 |
+
character_dialogue로 인식하는 형식)으로 — 그래야 화자명·초상 연출이 붙는다
|
| 35 |
+
- 초상이 있는 인물: 카르밀라·아버지·마드모아젤·마담 (추가 시 개발팀에 전달)
|
| 36 |
+
|
| 37 |
+
## 4. 장소 태그가 배경을 결정한다
|
| 38 |
+
|
| 39 |
+
- `#장소/X` 태그의 첫 항목이 그 카드의 배경 이미지를 고른다
|
| 40 |
+
(현재 매핑: 침실·만찬홀·숲길·해자·폐허·예배당 — 정원·화랑·서재는 배경 이미지 대기 중)
|
| 41 |
+
- 장소가 바뀌는 카드에는 태그를 꼭 갱신할 것 — 없으면 직전 배경이 이어진다
|
docs/contract/demo_api.md
CHANGED
|
@@ -15,14 +15,22 @@
|
|
| 15 |
"episode_title": "제1화: 어린 밤의 손님"},
|
| 16 |
"narration": [
|
| 17 |
{"type": "prose|dialogue_center|character_dialogue|stage_direction",
|
| 18 |
-
"text": "...", "speaker": ""
|
| 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 |
-
// 파티션 ·
|
|
|
|
|
|
|
| 26 |
// card.id = 페이지 끝 카드(선택·채팅 기준),
|
| 27 |
// title/episode = 첫 카드 기준.
|
| 28 |
// 연출 컷은 단독 페이지 + 표시 2페이지(컷+본문) —
|
|
@@ -49,7 +57,7 @@
|
|
| 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 ·
|
|
|
|
| 15 |
"episode_title": "제1화: 어린 밤의 손님"},
|
| 16 |
"narration": [
|
| 17 |
{"type": "prose|dialogue_center|character_dialogue|stage_direction",
|
| 18 |
+
"text": "...", "speaker": "", // speaker는 character_dialogue만
|
| 19 |
+
"role": "lead_in|aftermath|null", // 화면 배치 역할 — lead_in(발화 직전 짧은 도입)은
|
| 20 |
+
// 스테이지 화면 상단에 얹힌다 (유도: 적재 시 규칙)
|
| 21 |
+
"portrait": "/assets/인물/카르밀라.png"} // 화자 초상 (router SPEAKER_PORTRAIT,
|
| 22 |
+
], // 없으면 null → 이름표만. 유저·로라는 프론트가
|
| 23 |
+
// 1인칭(오른쪽 정렬)으로 처리
|
| 24 |
"choices": [{"label": "눈을 뜬다"}], // 없으면 []
|
| 25 |
"can_chat": true, // 개입 포켓(자유채팅) 가능 페이지
|
| 26 |
"image": "/assets/플래이 이미지/4.png", // 연출 컷 (없으면 null). 매핑: router IMAGE_MAP
|
| 27 |
+
"background": "/assets/배경/침실.png", // 장소 배경 (카드 #장소/ 태그 → router BACKGROUND_MAP,
|
| 28 |
+
// 매핑 없는 장소는 직전 배경 유지 · 엔딩은 null)
|
| 29 |
"progress": {"index": 5, "total": 44}, // 읽기 진행도 (페이지 단위)
|
| 30 |
// 페이지 = 여러 카드 병합(services/paging.py, 결정적
|
| 31 |
+
// 파티션 · run별 균형 분할, 목표 chapter.page_target_chars
|
| 32 |
+
// 기본 500자). card.title은 참고용 — 클라이언트는 카드
|
| 33 |
+
// 소제목을 표시하지 않는다(에피소드 헤더·컷 캡션만).
|
| 34 |
// card.id = 페이지 끝 카드(선택·채팅 기준),
|
| 35 |
// title/episode = 첫 카드 기준.
|
| 36 |
// 연출 컷은 단독 페이지 + 표시 2페이지(컷+본문) —
|
|
|
|
| 57 |
| `POST /api/session/{sid}/pocket/open` | `{}` | `{"opened": true}` | 현재 카드에서 채팅 시작. can_chat=false면 409 |
|
| 58 |
| `POST /api/session/{sid}/pocket/turn` | `{"text": "..."}` | `PocketTurn` | converged=true면 자동 close(커밋) 완료 상태 |
|
| 59 |
| `POST /api/session/{sid}/pocket/close` | `{}` | `{"closed": true}` | 유저가 먼저 대화를 접을 때 (커밋 포함) |
|
| 60 |
+
| `GET /api/meta` | — | `{"cover_image","title","subtitle","intro_images"}` | 표지·인트로 시퀀스(작품/인물 소개·1장 시작 — 새 시작에만 표시, 이어 읽기는 건너뜀) |
|
| 61 |
| `GET /assets/…` | — | 이미지 파일 | 연출 에셋 (대표님 산출물, 읽기 전용) |
|
| 62 |
|
| 63 |
오류: 존재하지 않는 세션 **또는 콘텐츠 갱신으로 무효화된 세션** 404 ·
|
engine/__pycache__/router.cpython-312.pyc
CHANGED
|
Binary files a/engine/__pycache__/router.cpython-312.pyc and b/engine/__pycache__/router.cpython-312.pyc differ
|
|
|
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/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/content.py
CHANGED
|
@@ -12,7 +12,7 @@ from __future__ import annotations
|
|
| 12 |
import hashlib
|
| 13 |
from pathlib import Path
|
| 14 |
|
| 15 |
-
from engine.schemas.beatcard import BeatCard, RCard
|
| 16 |
from engine.schemas.character import CharacterSheet
|
| 17 |
from engine.schemas.chapter import Chapter
|
| 18 |
from engine.schemas.validate import check_links, load_content
|
|
@@ -45,6 +45,7 @@ class ContentRepository:
|
|
| 45 |
for ep in episodes:
|
| 46 |
self.episode_entry[ep.episode.id] = ep.cards[0].id
|
| 47 |
for card in ep.cards:
|
|
|
|
| 48 |
self._cards[card.id] = card
|
| 49 |
self._order[card.id] = i
|
| 50 |
i += 1
|
|
|
|
| 12 |
import hashlib
|
| 13 |
from pathlib import Path
|
| 14 |
|
| 15 |
+
from engine.schemas.beatcard import BeatCard, RCard, derive_roles
|
| 16 |
from engine.schemas.character import CharacterSheet
|
| 17 |
from engine.schemas.chapter import Chapter
|
| 18 |
from engine.schemas.validate import check_links, load_content
|
|
|
|
| 45 |
for ep in episodes:
|
| 46 |
self.episode_entry[ep.episode.id] = ep.cards[0].id
|
| 47 |
for card in ep.cards:
|
| 48 |
+
derive_roles(card.narration) # role 미지정 블록에 유도 규칙 (명시값 존중)
|
| 49 |
self._cards[card.id] = card
|
| 50 |
self._order[card.id] = i
|
| 51 |
i += 1
|
engine/router.py
CHANGED
|
@@ -159,6 +159,7 @@ def _step_json(ps: PlaySession) -> dict:
|
|
| 159 |
"card": {"id": last.id, "title": first.title, "episode": first.episode_id,
|
| 160 |
"episode_title": ps.episode_titles.get(first.episode_id, first.episode_id)},
|
| 161 |
"narration": [{"type": b.type, "text": b.text, "speaker": b.speaker,
|
|
|
|
| 162 |
"portrait": (SPEAKER_PORTRAIT.get(b.speaker)
|
| 163 |
if b.type == "character_dialogue" else None)}
|
| 164 |
for b in page.narration],
|
|
|
|
| 159 |
"card": {"id": last.id, "title": first.title, "episode": first.episode_id,
|
| 160 |
"episode_title": ps.episode_titles.get(first.episode_id, first.episode_id)},
|
| 161 |
"narration": [{"type": b.type, "text": b.text, "speaker": b.speaker,
|
| 162 |
+
"role": b.role,
|
| 163 |
"portrait": (SPEAKER_PORTRAIT.get(b.speaker)
|
| 164 |
if b.type == "character_dialogue" else None)}
|
| 165 |
for b in page.narration],
|
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__/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/beatcard.py
CHANGED
|
@@ -19,6 +19,16 @@ Axis = Literal["trust", "doubt", "neutral"]
|
|
| 19 |
NARRATION_TYPES = ("prose", "dialogue_center", "stage_direction", "character_dialogue")
|
| 20 |
|
| 21 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
class NarrationBlock(BaseModel):
|
| 23 |
"""고정 지문 블록. gate가 있으면 신뢰 레벨에 따라 노출이 갈린다."""
|
| 24 |
|
|
@@ -29,6 +39,16 @@ class NarrationBlock(BaseModel):
|
|
| 29 |
speaker: str = ""
|
| 30 |
anchor: bool = False # True = 개입 포켓에서 LLM 변주 대상 앵커 대사
|
| 31 |
gate: Literal["all", "trust_high"] = "all" # 〈표면·전원〉 / 〈심층·신뢰高 해금〉
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
|
| 33 |
|
| 34 |
class Choice(BaseModel):
|
|
|
|
| 19 |
NARRATION_TYPES = ("prose", "dialogue_center", "stage_direction", "character_dialogue")
|
| 20 |
|
| 21 |
|
| 22 |
+
# 발화 직전의 이 길이 이하 prose는 리드인 — 렌더가 스테이지 화면 상단에 얹는다
|
| 23 |
+
LEAD_IN_MAX_CHARS = 140
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def is_lead_in(block_type: str, text: str, next_type: str) -> bool:
|
| 27 |
+
"""리드인 유도 규칙 (단일 권위 — 변환기·적재가 공유)."""
|
| 28 |
+
return (block_type == "prose" and len(text) <= LEAD_IN_MAX_CHARS
|
| 29 |
+
and next_type == "character_dialogue")
|
| 30 |
+
|
| 31 |
+
|
| 32 |
class NarrationBlock(BaseModel):
|
| 33 |
"""고정 지문 블록. gate가 있으면 신뢰 레벨에 따라 노출이 갈린다."""
|
| 34 |
|
|
|
|
| 39 |
speaker: str = ""
|
| 40 |
anchor: bool = False # True = 개입 포켓에서 LLM 변주 대상 앵커 대사
|
| 41 |
gate: Literal["all", "trust_high"] = "all" # 〈표면·전원〉 / 〈심층·신뢰高 해금〉
|
| 42 |
+
# 화면 배치 역할 (연출 구조화). 미지정이면 적재 시 유도 규칙(derive_roles) 적용:
|
| 43 |
+
# lead_in = 발화 직전의 짧은 도입 prose (스테이지 화면에 얹힘) · aftermath = 예약
|
| 44 |
+
role: Optional[Literal["lead_in", "aftermath"]] = None
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def derive_roles(narration: list[NarrationBlock]) -> None:
|
| 48 |
+
"""role 미지정 블록에 유도 규칙 적용 (제자리 갱신 — 명시 role은 존중)."""
|
| 49 |
+
for i, b in enumerate(narration[:-1]):
|
| 50 |
+
if b.role is None and is_lead_in(b.type, b.text, narration[i + 1].type):
|
| 51 |
+
b.role = "lead_in"
|
| 52 |
|
| 53 |
|
| 54 |
class Choice(BaseModel):
|
engine/schemas/validate.py
CHANGED
|
@@ -119,6 +119,15 @@ def check_links(episodes: list[EpisodeFile], chapter, r_cards) -> tuple[list[str
|
|
| 119 |
if not has_out:
|
| 120 |
warnings.append(f"{c.id}: 나가는 링크 없음 (챕터 출구가 아니면 결함)")
|
| 121 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 122 |
return errors, warnings
|
| 123 |
|
| 124 |
|
|
|
|
| 119 |
if not has_out:
|
| 120 |
warnings.append(f"{c.id}: 나가는 링크 없음 (챕터 출구가 아니면 결함)")
|
| 121 |
|
| 122 |
+
# 빈약 카드 — 화면 하나의 최소 밀도 미달 (작성 가이드: docs/contract/beatcard_guide.md)
|
| 123 |
+
MIN_CARD_CHARS = 200
|
| 124 |
+
for c in cards.values():
|
| 125 |
+
total = sum(len(b.text) for b in c.narration)
|
| 126 |
+
if total < MIN_CARD_CHARS:
|
| 127 |
+
warnings.append(
|
| 128 |
+
f"{c.id}: 지문 {total}자 — 화면 최소 권장({MIN_CARD_CHARS}자) 미달. "
|
| 129 |
+
f"보강하거나 이웃 카드와의 병합을 검토 (대표님 판단)")
|
| 130 |
+
|
| 131 |
return errors, warnings
|
| 132 |
|
| 133 |
|
engine/services/__pycache__/paging.cpython-312.pyc
CHANGED
|
Binary files a/engine/services/__pycache__/paging.cpython-312.pyc and b/engine/services/__pycache__/paging.cpython-312.pyc differ
|
|
|
engine/services/__pycache__/paging.cpython-314.pyc
CHANGED
|
Binary files a/engine/services/__pycache__/paging.cpython-314.pyc and b/engine/services/__pycache__/paging.cpython-314.pyc differ
|
|
|
engine/services/paging.py
CHANGED
|
@@ -6,11 +6,12 @@
|
|
| 6 |
(카드당 정확히 1회 — 이중/누락 적용 금지)
|
| 7 |
- 파티션은 콘텐츠(전역 카드 순서)의 순수 함수 — 유저 경로·상태와 무관해
|
| 8 |
페이지 총수(진행도 분모)가 안정적이고, 복원 시에도 같은 페이지가 재구성된다
|
| 9 |
-
- 병합은 선형 링크(card.next == 전역 다음 카드)로 이어진 구간에서만. 강제 분리:
|
| 10 |
· 선택지/분기(next_options) 카드 뒤 (결정 지점)
|
| 11 |
· solo 카드(연출 컷·채팅 앵커— 호출자가 지정) 앞뒤
|
| 12 |
· 에피소드 경계
|
| 13 |
-
-
|
|
|
|
| 14 |
"""
|
| 15 |
|
| 16 |
from __future__ import annotations
|
|
@@ -47,20 +48,23 @@ class Pager:
|
|
| 47 |
|
| 48 |
# ── 정적 파티션 ─────────────────────────────────────────
|
| 49 |
def _build(self) -> None:
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
cur: list[
|
| 53 |
-
cur_chars = 0
|
| 54 |
prev: Optional[BeatCard] = None
|
| 55 |
-
for card in
|
| 56 |
-
if cur and self.
|
| 57 |
-
|
| 58 |
-
cur
|
| 59 |
-
cur.append(card
|
| 60 |
-
cur_chars += sum(len(b.text) for b in card.narration)
|
| 61 |
prev = card
|
| 62 |
if cur:
|
| 63 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
|
| 65 |
self.partitions = parts
|
| 66 |
self.part_of = {cid: i for i, part in enumerate(parts) for cid in part}
|
|
@@ -73,16 +77,48 @@ class Pager:
|
|
| 73 |
seen += 1
|
| 74 |
self._total = len(parts) + seen
|
| 75 |
|
| 76 |
-
def
|
| 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 |
-
|
| 84 |
-
|
| 85 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
|
| 87 |
# ── 표시 번호 (진행도) ──────────────────────────────────
|
| 88 |
@property
|
|
|
|
| 6 |
(카드당 정확히 1회 — 이중/누락 적용 금지)
|
| 7 |
- 파티션은 콘텐츠(전역 카드 순서)의 순수 함수 — 유저 경로·상태와 무관해
|
| 8 |
페이지 총수(진행도 분모)가 안정적이고, 복원 시에도 같은 페이지가 재구성된다
|
| 9 |
+
- 병합은 선형 링크(card.next == 전역 다음 카드)로 이어진 구간(run)에서만. 강제 분리:
|
| 10 |
· 선택지/분기(next_options) 카드 뒤 (결정 지점)
|
| 11 |
· solo 카드(연출 컷·채팅 앵커— 호출자가 지정) 앞뒤
|
| 12 |
· 에피소드 경계
|
| 13 |
+
- 밀도 균형: run별 총 글자수로 페이지 수 k = round(총/target)를 먼저 정하고,
|
| 14 |
+
누적 글자수가 균등 경계(총·i/k)에 가장 가까운 카드 뒤에서 절단 — 그리디 과적 방지
|
| 15 |
"""
|
| 16 |
|
| 17 |
from __future__ import annotations
|
|
|
|
| 48 |
|
| 49 |
# ── 정적 파티션 ─────────────────────────────────────────
|
| 50 |
def _build(self) -> None:
|
| 51 |
+
# 1) 구조적 브레이크로 run(병합 가능한 최대 구간)을 만들고
|
| 52 |
+
runs: list[list[BeatCard]] = []
|
| 53 |
+
cur: list[BeatCard] = []
|
|
|
|
| 54 |
prev: Optional[BeatCard] = None
|
| 55 |
+
for card in self.repo.all_cards():
|
| 56 |
+
if cur and self._structural_break(prev, card):
|
| 57 |
+
runs.append(cur)
|
| 58 |
+
cur = []
|
| 59 |
+
cur.append(card)
|
|
|
|
| 60 |
prev = card
|
| 61 |
if cur:
|
| 62 |
+
runs.append(cur)
|
| 63 |
+
|
| 64 |
+
# 2) run별로 균형 분할
|
| 65 |
+
parts: list[list[str]] = []
|
| 66 |
+
for run in runs:
|
| 67 |
+
parts += [[c.id for c in part] for part in self._split_run(run)]
|
| 68 |
|
| 69 |
self.partitions = parts
|
| 70 |
self.part_of = {cid: i for i, part in enumerate(parts) for cid in part}
|
|
|
|
| 77 |
seen += 1
|
| 78 |
self._total = len(parts) + seen
|
| 79 |
|
| 80 |
+
def _structural_break(self, prev: BeatCard, card: BeatCard) -> bool:
|
| 81 |
if prev.choices or prev.next_options:
|
| 82 |
return True # 결정 지점 — 페이지는 선택지로 닫힌다
|
| 83 |
if prev.next != card.id:
|
| 84 |
return True # 비선형(분기 변형·점프·에피소드 참조) — 경로 안전을 위해 분리
|
| 85 |
if prev.id in self.solo or card.id in self.solo:
|
| 86 |
return True
|
| 87 |
+
return prev.episode_id != card.episode_id
|
| 88 |
+
|
| 89 |
+
@staticmethod
|
| 90 |
+
def _chars(card: BeatCard) -> int:
|
| 91 |
+
return sum(len(b.text) for b in card.narration)
|
| 92 |
+
|
| 93 |
+
def _split_run(self, run: list[BeatCard]) -> list[list[BeatCard]]:
|
| 94 |
+
"""run을 k = round(총 글자수/target)개의 연속 구간으로 균등 분할 (결정적)."""
|
| 95 |
+
total = sum(self._chars(c) for c in run)
|
| 96 |
+
k = max(1, min(len(run), round(total / self.target)))
|
| 97 |
+
if k == 1:
|
| 98 |
+
return [run]
|
| 99 |
+
bounds = [total * i / k for i in range(1, k)] # 균등 절단 목표점
|
| 100 |
+
parts: list[list[BeatCard]] = []
|
| 101 |
+
cur: list[BeatCard] = []
|
| 102 |
+
acc = 0
|
| 103 |
+
bi = 0
|
| 104 |
+
for idx, card in enumerate(run):
|
| 105 |
+
cur.append(card)
|
| 106 |
+
acc += self._chars(card)
|
| 107 |
+
remaining_cards = len(run) - idx - 1
|
| 108 |
+
remaining_cuts = (k - 1) - bi
|
| 109 |
+
if bi >= len(bounds) or remaining_cards == 0:
|
| 110 |
+
continue
|
| 111 |
+
next_acc = acc + self._chars(run[idx + 1])
|
| 112 |
+
# 지금 자르는 게 다음 카드까지 끌고 가는 것보다 경계에 가깝거나,
|
| 113 |
+
# 남은 절단 수만큼의 카드밖에 안 남았으면 여기서 절단
|
| 114 |
+
if (abs(acc - bounds[bi]) <= abs(next_acc - bounds[bi])
|
| 115 |
+
or remaining_cards == remaining_cuts):
|
| 116 |
+
parts.append(cur)
|
| 117 |
+
cur = []
|
| 118 |
+
bi += 1
|
| 119 |
+
if cur:
|
| 120 |
+
parts.append(cur)
|
| 121 |
+
return parts
|
| 122 |
|
| 123 |
# ── 표시 번호 (진행도) ──────────────────────────────────
|
| 124 |
@property
|
scripts/beatcard_convert.py
CHANGED
|
@@ -278,6 +278,11 @@ class EpisodeParser:
|
|
| 278 |
|
| 279 |
flush_prose()
|
| 280 |
close_result()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 281 |
# 스키마 기본값과 같은 키 제거 없이 그대로 두되, speaker/anchor 없는 블록은 기본값 생략
|
| 282 |
narration = [
|
| 283 |
{k: v for k, v in b.items() if not (k == "speaker" and not v)
|
|
|
|
| 278 |
|
| 279 |
flush_prose()
|
| 280 |
close_result()
|
| 281 |
+
# 화면 배치 역할 유도 — 규칙 권위는 engine.schemas.beatcard.is_lead_in (적재 시에도 동일 적용)
|
| 282 |
+
from engine.schemas.beatcard import is_lead_in
|
| 283 |
+
for i, b in enumerate(narration[:-1]):
|
| 284 |
+
if "role" not in b and is_lead_in(b["type"], b["text"], narration[i + 1]["type"]):
|
| 285 |
+
b["role"] = "lead_in"
|
| 286 |
# 스키마 기본값과 같은 키 제거 없이 그대로 두되, speaker/anchor 없는 블록은 기본값 생략
|
| 287 |
narration = [
|
| 288 |
{k: v for k, v in b.items() if not (k == "speaker" and not v)
|
tests/__init__.py
ADDED
|
File without changes
|
tests/__pycache__/__init__.cpython-314.pyc
ADDED
|
Binary file (153 Bytes). View file
|
|
|
tests/__pycache__/fakes.cpython-314.pyc
ADDED
|
Binary file (3.43 kB). View file
|
|
|
tests/__pycache__/test_integration.cpython-314-pytest-9.1.1.pyc
ADDED
|
Binary file (14.2 kB). View file
|
|
|
tests/__pycache__/test_paging.cpython-314-pytest-9.1.1.pyc
ADDED
|
Binary file (43.9 kB). View file
|
|
|
tests/__pycache__/test_persistence.cpython-314-pytest-9.1.1.pyc
ADDED
|
Binary file (29.3 kB). View file
|
|
|
tests/__pycache__/test_pocket.cpython-314-pytest-9.1.1.pyc
ADDED
|
Binary file (19.3 kB). View file
|
|
|
tests/__pycache__/test_story.cpython-314-pytest-9.1.1.pyc
ADDED
|
Binary file (20.8 kB). View file
|
|
|
tests/fakes.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""테스트 더블 — ScriptedLlm: 정해진 응답을 순서대로 내놓는 BaseLlm.
|
| 2 |
+
|
| 3 |
+
LLM 호출 없이 파이프라인(가드→캐릭터→디렉터→도구→state)을 결정적으로 검증한다.
|
| 4 |
+
프로덕션 경로는 LiteLlm(engine/model.py) 그대로이며, 이 더블은 tests/ 전용이다.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
from typing import AsyncGenerator
|
| 10 |
+
|
| 11 |
+
from google.adk.models.base_llm import BaseLlm
|
| 12 |
+
from google.adk.models.llm_request import LlmRequest
|
| 13 |
+
from google.adk.models.llm_response import LlmResponse
|
| 14 |
+
from google.genai import types
|
| 15 |
+
from pydantic import ConfigDict
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def text_response(text: str) -> LlmResponse:
|
| 19 |
+
return LlmResponse(content=types.Content(role="model", parts=[types.Part(text=text)]))
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def tool_call(name: str, **args) -> types.Part:
|
| 23 |
+
return types.Part(function_call=types.FunctionCall(name=name, args=args))
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def tool_response(*parts: types.Part) -> LlmResponse:
|
| 27 |
+
return LlmResponse(content=types.Content(role="model", parts=list(parts)))
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
class ScriptedLlm(BaseLlm):
|
| 31 |
+
model_config = ConfigDict(arbitrary_types_allowed=True)
|
| 32 |
+
|
| 33 |
+
model: str = "scripted"
|
| 34 |
+
script: list[LlmResponse] = []
|
| 35 |
+
requests: list[LlmRequest] = [] # 검증용: 실제로 주입된 instruction 확인
|
| 36 |
+
|
| 37 |
+
async def generate_content_async(
|
| 38 |
+
self, llm_request: LlmRequest, stream: bool = False
|
| 39 |
+
) -> AsyncGenerator[LlmResponse, None]:
|
| 40 |
+
self.requests.append(llm_request)
|
| 41 |
+
if not self.script:
|
| 42 |
+
yield text_response("(대본 소진)")
|
| 43 |
+
return
|
| 44 |
+
yield self.script.pop(0)
|
tests/test_integration.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""M4 전체 관통 — 읽기 루프 → 개입 포켓(채팅) → 복귀 → 엔딩까지 한 번에."""
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
|
| 5 |
+
import pytest
|
| 6 |
+
|
| 7 |
+
from engine.plugins.logging import TurnLoggingPlugin
|
| 8 |
+
from engine.repositories.content import ContentRepository
|
| 9 |
+
from engine.repositories.playlog import PlaylogRepository
|
| 10 |
+
from engine.services.pocket import PocketService
|
| 11 |
+
from engine.services.state import AXIS_DELTA
|
| 12 |
+
from engine.services.story import StoryEngine
|
| 13 |
+
from tests.fakes import ScriptedLlm, text_response, tool_call, tool_response
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
@pytest.fixture(scope="module")
|
| 17 |
+
def repo():
|
| 18 |
+
return ContentRepository("content")
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
@pytest.mark.asyncio
|
| 22 |
+
async def test_full_playthrough_with_pocket(repo, tmp_path):
|
| 23 |
+
"""E06-09까지 읽고 → 포켓 채팅 2턴(수렴) → 상태 커밋 → 이어서 엔딩 도달."""
|
| 24 |
+
playlog = PlaylogRepository(tmp_path / "playlog.jsonl")
|
| 25 |
+
svc = PocketService(
|
| 26 |
+
repo,
|
| 27 |
+
plugins=[TurnLoggingPlugin(playlog)],
|
| 28 |
+
character_model=ScriptedLlm(script=[
|
| 29 |
+
text_response("…그래. 너와 나 사이의 일이야, 로라."),
|
| 30 |
+
text_response("착하지. 우리끼리만 간직하자."),
|
| 31 |
+
text_response("…이제 됐어. 아무 말도 필요 없어."),
|
| 32 |
+
]),
|
| 33 |
+
director_model=ScriptedLlm(script=[
|
| 34 |
+
tool_response(tool_call("update_trust", direction="trust")),
|
| 35 |
+
text_response("계속"),
|
| 36 |
+
tool_response(tool_call("update_trust", direction="trust"),
|
| 37 |
+
tool_call("mark_beat", beat="둘만의 비밀 동조")),
|
| 38 |
+
text_response("계속"),
|
| 39 |
+
tool_response(tool_call("finish_pocket", reason="goal_reached")),
|
| 40 |
+
text_response("수렴"),
|
| 41 |
+
]),
|
| 42 |
+
)
|
| 43 |
+
|
| 44 |
+
# 1) 읽기 루프: E06-09까지 신뢰 성향으로 진행
|
| 45 |
+
engine = StoryEngine(repo)
|
| 46 |
+
step = engine.start()
|
| 47 |
+
while engine.current_id != "E06-09":
|
| 48 |
+
if step.choices:
|
| 49 |
+
idx = next((i for i, c in enumerate(step.choices) if c.axis == "trust"),
|
| 50 |
+
next((i for i, c in enumerate(step.choices)
|
| 51 |
+
if c.axis in (None, "neutral")), 0))
|
| 52 |
+
_, step = engine.choose(idx)
|
| 53 |
+
else:
|
| 54 |
+
step = engine.advance()
|
| 55 |
+
trust_before = engine.state.trust_score
|
| 56 |
+
|
| 57 |
+
# 2) 개입 포켓: 자유채팅 3턴 → director 수렴 (최소 체류 3턴)
|
| 58 |
+
sid = await svc.open("E06-09", engine.state)
|
| 59 |
+
t1 = await svc.run_turn(sid, "…우리 둘만의 일이라는 거지?")
|
| 60 |
+
assert not t1.converged and "로라" in t1.reply
|
| 61 |
+
t2 = await svc.run_turn(sid, "알았어. 아무에게도 말하지 않을게.")
|
| 62 |
+
assert not t2.converged # 최소 체류 턴 미달
|
| 63 |
+
t3 = await svc.run_turn(sid, "…응. 약속할게.")
|
| 64 |
+
assert t3.converged and t3.reason == "goal_reached"
|
| 65 |
+
|
| 66 |
+
# 3) 복귀: 숨은 상태 커밋 (채팅 2턴 = trust +2×AXIS_DELTA)
|
| 67 |
+
log = await svc.close(sid, engine.state)
|
| 68 |
+
assert engine.state.trust_score == trust_before + 2 * AXIS_DELTA
|
| 69 |
+
assert log.beats_hit == ["둘만의 비밀 동조"]
|
| 70 |
+
|
| 71 |
+
# 4) 이어서 읽기 → 엔딩 (신뢰 성향 유지 → 영원)
|
| 72 |
+
while step.ending is None:
|
| 73 |
+
if step.choices:
|
| 74 |
+
idx = next((i for i, c in enumerate(step.choices) if c.axis == "trust"),
|
| 75 |
+
next((i for i, c in enumerate(step.choices)
|
| 76 |
+
if c.axis in (None, "neutral")), 0))
|
| 77 |
+
_, step = engine.choose(idx)
|
| 78 |
+
else:
|
| 79 |
+
step = engine.advance()
|
| 80 |
+
assert step.ending.id == "ending_eternal"
|
| 81 |
+
assert "E06-10" in engine.visited # 도달점은 채팅과 무관하게 통과
|
| 82 |
+
|
| 83 |
+
# 5) 턴 로그: 유저 입력/이벤트/턴 요약이 기록됨 (연구 데이터)
|
| 84 |
+
records = [json.loads(l) for l in (tmp_path / "playlog.jsonl").read_text().splitlines()]
|
| 85 |
+
kinds = {r["kind"] for r in records}
|
| 86 |
+
assert {"user_input", "event", "turn_summary"} <= kinds
|
| 87 |
+
tool_events = [r for r in records if r.get("tool_calls")]
|
| 88 |
+
assert any(c["tool"] == "finish_pocket" for r in tool_events for c in r["tool_calls"])
|
tests/test_paging.py
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""페이지 병합(Pager) 회귀 — 파티션 불변식 · 숨은 상태 등가성 · 복원 렌더.
|
| 2 |
+
|
| 3 |
+
핵심 회귀: 병합해도 카드당 부수효과(쇠약도/신뢰축)는 정확히 1회 — 카드 단위 순수 걷기와
|
| 4 |
+
최종 숨은 상태·엔딩이 완전히 일치해야 한다.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import pytest
|
| 8 |
+
|
| 9 |
+
from engine import router
|
| 10 |
+
from engine.repositories.content import ContentRepository
|
| 11 |
+
from engine.repositories.play_session import PlaySessionRepository
|
| 12 |
+
from engine.services.paging import Pager
|
| 13 |
+
from engine.services.story import StoryEngine
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
@pytest.fixture(scope="module")
|
| 17 |
+
def repo():
|
| 18 |
+
return ContentRepository("content")
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
@pytest.fixture(scope="module")
|
| 22 |
+
def pager(repo):
|
| 23 |
+
cuts = {cid for cid in router.IMAGE_MAP if repo.has_card(cid)}
|
| 24 |
+
solo = cuts | {c.id for c in repo.all_cards() if c.can_chat}
|
| 25 |
+
return Pager(repo, solo_ids=solo, cut_ids=cuts)
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def pick(choices, policy):
|
| 29 |
+
idx = next((i for i, c in enumerate(choices) if c.axis == policy), None)
|
| 30 |
+
if idx is None:
|
| 31 |
+
idx = next((i for i, c in enumerate(choices) if c.axis in (None, "neutral")), 0)
|
| 32 |
+
return idx
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def play_paged(repo, pager, policy):
|
| 36 |
+
"""라우터와 동일한 페이지 걷기. (engine, [PageView…]) 반환."""
|
| 37 |
+
engine = StoryEngine(repo)
|
| 38 |
+
pages = [pager.complete(engine, engine.start())]
|
| 39 |
+
while pages[-1].ending is None:
|
| 40 |
+
page = pages[-1]
|
| 41 |
+
if page.choices:
|
| 42 |
+
_, step = engine.choose(pick(page.choices, policy))
|
| 43 |
+
else:
|
| 44 |
+
step = engine.advance()
|
| 45 |
+
pages.append(pager.complete(engine, step))
|
| 46 |
+
return engine, pages
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def play_cards(repo, policy):
|
| 50 |
+
"""카드 단위 순수 걷기 (기준 대조군)."""
|
| 51 |
+
engine = StoryEngine(repo)
|
| 52 |
+
step = engine.start()
|
| 53 |
+
while step.ending is None:
|
| 54 |
+
if step.choices:
|
| 55 |
+
_, step = engine.choose(pick(step.choices, policy))
|
| 56 |
+
else:
|
| 57 |
+
step = engine.advance()
|
| 58 |
+
return engine
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
# ── 파티션 불변식 ───────────────────────────────────────────
|
| 62 |
+
def test_partitions_cover_all_cards_once_in_order(repo, pager):
|
| 63 |
+
flat = [cid for part in pager.partitions for cid in part]
|
| 64 |
+
assert flat == [c.id for c in repo.all_cards()]
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def test_partition_break_invariants(repo, pager):
|
| 68 |
+
for part in pager.partitions:
|
| 69 |
+
cards = [repo.get_card(cid) for cid in part]
|
| 70 |
+
for a, b in zip(cards, cards[1:]):
|
| 71 |
+
# 페이지 내부는 선형 링크 + 결정 지점/에피소드 경계 없음
|
| 72 |
+
assert a.next == b.id
|
| 73 |
+
assert not a.choices and not a.next_options
|
| 74 |
+
assert a.episode_id == b.episode_id
|
| 75 |
+
# solo(연출 컷·채팅 앵커)는 단독 페이지
|
| 76 |
+
for c in cards:
|
| 77 |
+
if c.id in pager.solo:
|
| 78 |
+
assert len(part) == 1
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def test_balanced_density_band(repo, pager):
|
| 82 |
+
"""균형 분할 — 병합(멀티카드) 페이지는 목표 대비 과적/빈약 없이 밴드 안에 든다."""
|
| 83 |
+
def chars(part):
|
| 84 |
+
return sum(len(b.text) for cid in part for b in repo.get_card(cid).narration)
|
| 85 |
+
multi = [chars(p) for p in pager.partitions if len(p) > 1]
|
| 86 |
+
assert multi, "병합 페이지가 있어야 한다"
|
| 87 |
+
assert max(multi) <= pager.target * 1.7 # 그리디 과적(기존 964자) 재발 방지
|
| 88 |
+
assert min(multi) >= pager.target * 0.55 # 파편 병합 실패 방지
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def test_merging_actually_reduces_pages(repo, pager):
|
| 92 |
+
assert len(pager.partitions) < repo.total_cards # 61보다 적어야 병합 의미가 있다
|
| 93 |
+
assert pager.display_total == len(pager.partitions) + len(pager.cuts & set(pager.part_of))
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
# ── 숨은 상태 등가성 (핵심 회귀) ────────────────────────────
|
| 97 |
+
@pytest.mark.parametrize("policy", ["trust", "doubt", "neutral"])
|
| 98 |
+
def test_paged_walk_equals_card_walk(repo, pager, policy):
|
| 99 |
+
paged, _ = play_paged(repo, pager, policy)
|
| 100 |
+
plain = play_cards(repo, policy)
|
| 101 |
+
assert paged.ending.id == plain.ending.id
|
| 102 |
+
assert paged.state.snapshot() == plain.state.snapshot() # 부수효과 정확히 1회
|
| 103 |
+
assert paged.visited == plain.visited
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
# ── 진행도 표시 ─────────────────────────────────────────────
|
| 107 |
+
def test_display_index_monotonic_over_playthrough(repo, pager):
|
| 108 |
+
engine, pages = play_paged(repo, pager, "trust")
|
| 109 |
+
seen = [pager.display_index(p.last.id) for p in pages if p.ending is None]
|
| 110 |
+
assert seen == sorted(seen) # 진행도는 절대 뒤로 가지 않는다
|
| 111 |
+
assert seen[0] == 1
|
| 112 |
+
assert seen[-1] <= pager.display_total
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
# ── 복원 렌더 ───────────────────────────────────────────────
|
| 116 |
+
def test_render_current_matches_played_page(repo, pager):
|
| 117 |
+
mid = StoryEngine(repo)
|
| 118 |
+
mid_pages = [pager.complete(mid, mid.start())]
|
| 119 |
+
for _ in range(3):
|
| 120 |
+
mid_pages.append(pager.complete(mid, mid.advance() if not mid_pages[-1].choices
|
| 121 |
+
else mid.choose(pick(mid_pages[-1].choices, "trust"))[1]))
|
| 122 |
+
restored = StoryEngine.restore(repo, mid.snapshot())
|
| 123 |
+
view = pager.render_current(restored)
|
| 124 |
+
last_seen = mid_pages[-1]
|
| 125 |
+
assert [b.text for b in view.narration] == [b.text for b in last_seen.narration]
|
| 126 |
+
assert view.first.id == last_seen.first.id and view.last.id == last_seen.last.id
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
# ── 블록 역할 유도 (lead_in) ────────────────────────────────
|
| 130 |
+
def test_lead_in_roles_derived_on_load(repo):
|
| 131 |
+
"""발화 직전의 짧은 prose는 lead_in — E04-10 '한 문장 페이지' 회귀."""
|
| 132 |
+
card = repo.get_card("E04-10")
|
| 133 |
+
assert card.narration[0].type == "prose"
|
| 134 |
+
assert card.narration[0].role == "lead_in" # 39자 리드인
|
| 135 |
+
assert card.narration[2].role is None # 여파 prose는 미지정
|
| 136 |
+
|
| 137 |
+
# 긴 prose(140자 초과)가 발화 앞에 있어도 리드인이 아니다 — E01-03 첫 블록
|
| 138 |
+
long_case = repo.get_card("E01-03")
|
| 139 |
+
assert long_case.narration[0].type == "prose"
|
| 140 |
+
assert len(long_case.narration[0].text) > 140
|
| 141 |
+
assert long_case.narration[1].type == "character_dialogue"
|
| 142 |
+
assert long_case.narration[0].role is None
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
def test_thin_card_warnings():
|
| 146 |
+
"""C안 — 화면 최소 밀도 미달 카드는 validate 경고에 잡힌다."""
|
| 147 |
+
from pathlib import Path
|
| 148 |
+
|
| 149 |
+
from engine.schemas.validate import check_links, load_content
|
| 150 |
+
episodes, r_cards, chapter, *_ = load_content(Path("content"))
|
| 151 |
+
_, warnings = check_links(episodes, chapter, r_cards)
|
| 152 |
+
flagged = [w for w in warnings if "미달" in w]
|
| 153 |
+
assert any(w.startswith("E05-05") for w in flagged) # 96자 채팅 카드
|
| 154 |
+
assert any(w.startswith("E06-10C") for w in flagged) # 154자 분기 변형
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
# ── 라우터 레벨 ─────────────────────────────────────────────
|
| 158 |
+
@pytest.mark.asyncio
|
| 159 |
+
async def test_background_follows_place_tags_and_meta_has_intro(tmp_path, monkeypatch):
|
| 160 |
+
"""장소 태그 → 배경 매핑이 완주 동안 반영되고, 인트로 시퀀스가 메타에 실린다."""
|
| 161 |
+
monkeypatch.setattr(router, "_playdb", PlaySessionRepository(tmp_path / "s.db"))
|
| 162 |
+
monkeypatch.setattr(router, "_sessions", {})
|
| 163 |
+
meta = await router.meta()
|
| 164 |
+
assert meta["intro_images"] == router.INTRO_IMAGES and len(meta["intro_images"]) == 3
|
| 165 |
+
|
| 166 |
+
data = await router.create_session()
|
| 167 |
+
sid, step = data["session_id"], data["step"]
|
| 168 |
+
seen = {step["background"]}
|
| 169 |
+
portraits = set()
|
| 170 |
+
while step["ending"] is None:
|
| 171 |
+
portraits |= {n["portrait"] for n in step["narration"]
|
| 172 |
+
if n["type"] == "character_dialogue" and n["portrait"]}
|
| 173 |
+
if step["choices"]:
|
| 174 |
+
step = (await router.choose(sid, router.ChooseBody(index=0)))["step"]
|
| 175 |
+
else:
|
| 176 |
+
step = (await router.advance(sid))["step"]
|
| 177 |
+
seen.add(step["background"])
|
| 178 |
+
# 침실·만찬홀(식당)·숲길(안개 숲)은 어느 경로든 지나간다 + 엔딩은 배경 없음
|
| 179 |
+
assert {"/assets/배경/침실.png", "/assets/배경/식당.png", "/assets/배경/안개 숲.png"} <= seen
|
| 180 |
+
assert step["background"] is None
|
| 181 |
+
# 화자 초상 — 카르밀라·아버지는 어느 경로든 발화한다 (VN 발화 연출)
|
| 182 |
+
assert {"/assets/인물/카르밀라.png", "/assets/인물/아버지.png"} <= portraits
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
@pytest.mark.asyncio
|
| 186 |
+
async def test_router_serves_merged_pages(tmp_path, monkeypatch):
|
| 187 |
+
monkeypatch.setattr(router, "_playdb", PlaySessionRepository(tmp_path / "s.db"))
|
| 188 |
+
monkeypatch.setattr(router, "_sessions", {})
|
| 189 |
+
data = await router.create_session()
|
| 190 |
+
sid, step = data["session_id"], data["step"]
|
| 191 |
+
total = step["progress"]["total"]
|
| 192 |
+
assert total == router._pager.display_total
|
| 193 |
+
clicks = 1
|
| 194 |
+
while step["ending"] is None:
|
| 195 |
+
if step["choices"]:
|
| 196 |
+
step = (await router.choose(sid, router.ChooseBody(index=0)))["step"]
|
| 197 |
+
else:
|
| 198 |
+
step = (await router.advance(sid))["step"]
|
| 199 |
+
clicks += 1
|
| 200 |
+
assert clicks < 61 # 카드 수보다 적은 넘김으로 완주 = 병합 동작
|
tests/test_persistence.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""세션 영속화 회귀 — 스냅샷/복원 + SQLite 저장소 + 라우터 재시작 복원.
|
| 2 |
+
|
| 3 |
+
핵심 회귀: 복원은 _enter()를 거치지 않는다 — 쇠약도/신뢰축이 재적용되면 결함.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import pytest
|
| 7 |
+
from fastapi import HTTPException
|
| 8 |
+
|
| 9 |
+
from engine import router
|
| 10 |
+
from engine.repositories.content import ContentRepository
|
| 11 |
+
from engine.repositories.play_session import PlaySessionRepository
|
| 12 |
+
from engine.services.story import StoryEngine
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
@pytest.fixture(scope="module")
|
| 16 |
+
def repo():
|
| 17 |
+
return ContentRepository("content")
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def play_until(repo, stop_id: str | None, policy: str = "trust") -> StoryEngine:
|
| 21 |
+
"""stop_id 카드 도달(또는 None이면 엔딩)까지 진행."""
|
| 22 |
+
engine = StoryEngine(repo)
|
| 23 |
+
step = engine.start()
|
| 24 |
+
while step.ending is None and engine.current_id != stop_id:
|
| 25 |
+
if step.choices:
|
| 26 |
+
idx = next((i for i, c in enumerate(step.choices) if c.axis == policy),
|
| 27 |
+
next((i for i, c in enumerate(step.choices)
|
| 28 |
+
if c.axis in (None, "neutral")), 0))
|
| 29 |
+
_, step = engine.choose(idx)
|
| 30 |
+
else:
|
| 31 |
+
step = engine.advance()
|
| 32 |
+
return engine
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
# ── 스냅샷/복원 (엔진) ──────────────────────────────────────
|
| 36 |
+
def test_snapshot_restore_roundtrip(repo):
|
| 37 |
+
engine = play_until(repo, "E04-01")
|
| 38 |
+
snap = engine.snapshot()
|
| 39 |
+
restored = StoryEngine.restore(repo, snap)
|
| 40 |
+
|
| 41 |
+
assert restored.snapshot() == snap # 복원이 상태를 조금도 바꾸지 않는다
|
| 42 |
+
assert restored.current_id == engine.current_id
|
| 43 |
+
assert restored.visited == engine.visited
|
| 44 |
+
step = restored.current_step()
|
| 45 |
+
assert step.card.id == engine.current_id
|
| 46 |
+
assert step.ending is None
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def test_restore_does_not_reapply_enter_side_effects(repo):
|
| 50 |
+
"""핵심 회귀 — 복원 시 쇠약도·신뢰축이 재적용되면 안 된다 (_enter 우회 검증)."""
|
| 51 |
+
engine = play_until(repo, None) # 엔딩까지 = 쇠약도·축 이벤트 다수 누적
|
| 52 |
+
assert engine.state.frailty > 0 and engine.state.trust_score > 0
|
| 53 |
+
|
| 54 |
+
snap = engine.snapshot()
|
| 55 |
+
restored = StoryEngine.restore(repo, snap)
|
| 56 |
+
assert restored.state.frailty == engine.state.frailty
|
| 57 |
+
assert restored.state.trust_score == engine.state.trust_score
|
| 58 |
+
assert restored.state.doubt_score == engine.state.doubt_score
|
| 59 |
+
assert restored.state.axis_log == engine.state.axis_log
|
| 60 |
+
|
| 61 |
+
# 여러 번 복원해도 동일 (재적용이 있으면 여기서 어긋난다)
|
| 62 |
+
again = StoryEngine.restore(repo, restored.snapshot())
|
| 63 |
+
assert again.snapshot() == snap
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def test_ending_session_restores_to_ending(repo):
|
| 67 |
+
engine = play_until(repo, None, policy="trust")
|
| 68 |
+
assert engine.ending is not None
|
| 69 |
+
restored = StoryEngine.restore(repo, engine.snapshot())
|
| 70 |
+
step = restored.current_step()
|
| 71 |
+
assert step.ending is not None and step.ending.id == engine.ending.id
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
# ── SQLite 저장소 ───────────────────────────────────────────
|
| 75 |
+
def test_play_session_repo_save_load(tmp_path, repo):
|
| 76 |
+
db = PlaySessionRepository(tmp_path / "sessions.db")
|
| 77 |
+
engine = play_until(repo, "E02-01")
|
| 78 |
+
db.save("sid1", engine.snapshot(), repo.content_version)
|
| 79 |
+
|
| 80 |
+
loaded = db.load("sid1", repo.content_version)
|
| 81 |
+
assert loaded == engine.snapshot()
|
| 82 |
+
assert db.load("없는sid", repo.content_version) is None
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def test_content_version_mismatch_invalidates(tmp_path, repo):
|
| 86 |
+
db = PlaySessionRepository(tmp_path / "sessions.db")
|
| 87 |
+
db.save("sid1", {"story": {}, "hidden": {}}, "구버전해시")
|
| 88 |
+
assert db.load("sid1", repo.content_version) is None # 콘텐츠 갱신 → 무효화
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def test_purge_removes_stale_sessions(tmp_path):
|
| 92 |
+
db = PlaySessionRepository(tmp_path / "sessions.db")
|
| 93 |
+
db.save("sid1", {"story": {}, "hidden": {}}, "v")
|
| 94 |
+
assert db.purge(days=30) == 0 # 방금 저장 — 남는다
|
| 95 |
+
assert db.purge(days=0) == 1 # 즉시 만료 취급 — 지워진다
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
# ── 라우터 (재시작 모사) ────────────────────────────────────
|
| 99 |
+
@pytest.mark.asyncio
|
| 100 |
+
async def test_router_restores_after_restart(tmp_path, monkeypatch):
|
| 101 |
+
monkeypatch.setattr(router, "_playdb", PlaySessionRepository(tmp_path / "s.db"))
|
| 102 |
+
monkeypatch.setattr(router, "_sessions", {})
|
| 103 |
+
|
| 104 |
+
data = await router.create_session()
|
| 105 |
+
sid = data["session_id"]
|
| 106 |
+
step = data["step"]
|
| 107 |
+
while not step["choices"] and not step["ending"]: # 첫 선택지까지 읽기
|
| 108 |
+
step = (await router.advance(sid))["step"]
|
| 109 |
+
step = (await router.choose(sid, router.ChooseBody(index=0)))["step"]
|
| 110 |
+
current_card = step["card"]["id"]
|
| 111 |
+
|
| 112 |
+
router._sessions.clear() # 서버 재시작 모사 (인메모리 캐시 소실)
|
| 113 |
+
got = await router.get_session(sid)
|
| 114 |
+
assert got["step"]["card"]["id"] == current_card
|
| 115 |
+
assert router._sessions[sid].pocket_sid is None # 포켓은 비영속 — 닫힌 채 복원
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
@pytest.mark.asyncio
|
| 119 |
+
async def test_router_unknown_sid_404(tmp_path, monkeypatch):
|
| 120 |
+
monkeypatch.setattr(router, "_playdb", PlaySessionRepository(tmp_path / "s.db"))
|
| 121 |
+
monkeypatch.setattr(router, "_sessions", {})
|
| 122 |
+
with pytest.raises(HTTPException) as exc:
|
| 123 |
+
await router.get_session("없는세션")
|
| 124 |
+
assert exc.value.status_code == 404
|
tests/test_pocket.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""M3 개입 포켓 파이프라인 테스트 — ScriptedLlm으로 결정적 검증."""
|
| 2 |
+
|
| 3 |
+
import pytest
|
| 4 |
+
|
| 5 |
+
from engine.repositories.content import ContentRepository
|
| 6 |
+
from engine.services.pocket import PocketService
|
| 7 |
+
from engine.services.state import AXIS_DELTA, HiddenState
|
| 8 |
+
from tests.fakes import ScriptedLlm, text_response, tool_call, tool_response
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
@pytest.fixture(scope="module")
|
| 12 |
+
def repo():
|
| 13 |
+
return ContentRepository("content")
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def make_service(repo, char_script, dir_script):
|
| 17 |
+
char_llm = ScriptedLlm(script=list(char_script))
|
| 18 |
+
dir_llm = ScriptedLlm(script=list(dir_script))
|
| 19 |
+
return PocketService(repo, character_model=char_llm, director_model=dir_llm), char_llm, dir_llm
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
@pytest.mark.asyncio
|
| 23 |
+
async def test_turn_pipeline_moves_trust_and_converges(repo):
|
| 24 |
+
"""정상 턴: 캐릭터 응답 + director의 update_trust → state 반영, 수렴 재검증."""
|
| 25 |
+
svc, char_llm, _ = make_service(
|
| 26 |
+
repo,
|
| 27 |
+
char_script=[text_response("…그래, 로라. 우리끼리만 간직하자.")],
|
| 28 |
+
dir_script=[
|
| 29 |
+
tool_response(tool_call("update_trust", direction="trust"),
|
| 30 |
+
tool_call("mark_beat", beat="둘만의 비밀 동조")),
|
| 31 |
+
text_response("판정 완료"),
|
| 32 |
+
],
|
| 33 |
+
)
|
| 34 |
+
hidden = HiddenState()
|
| 35 |
+
sid = await svc.open("E06-09", hidden)
|
| 36 |
+
turn = await svc.run_turn(sid, "네 말이 맞아. 우리 둘만의 비밀로 하자.")
|
| 37 |
+
|
| 38 |
+
assert "간직하자" in turn.reply
|
| 39 |
+
assert turn.guard_flag is None
|
| 40 |
+
assert not turn.converged # finish_pocket 미호출 → 수렴 아님
|
| 41 |
+
|
| 42 |
+
log = await svc.close(sid, hidden)
|
| 43 |
+
assert hidden.trust_score == AXIS_DELTA # 커밋 경로로만 반영
|
| 44 |
+
assert log.beats_hit == ["둘만의 비밀 동조"]
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
@pytest.mark.asyncio
|
| 48 |
+
async def test_finish_pocket_verified_by_code(repo):
|
| 49 |
+
"""director의 goal_reached 선언은 비트가 있어야만 인정된다 (코드 재검증)."""
|
| 50 |
+
svc, _, _ = make_service(
|
| 51 |
+
repo,
|
| 52 |
+
char_script=[text_response("…"), text_response("…"), text_response("…")],
|
| 53 |
+
dir_script=[
|
| 54 |
+
# 1턴: 비트 없이 goal_reached 선언 → 기각되어야 함
|
| 55 |
+
tool_response(tool_call("finish_pocket", reason="goal_reached")),
|
| 56 |
+
text_response("끝"),
|
| 57 |
+
# 2턴: 비트는 있지만 MIN_TURNS(3) 미달 → 기각
|
| 58 |
+
tool_response(tool_call("mark_beat", beat="핵심 비트"),
|
| 59 |
+
tool_call("finish_pocket", reason="goal_reached")),
|
| 60 |
+
text_response("끝"),
|
| 61 |
+
# 3턴: 최소 체류 충족 + 비트 있음 → 인정
|
| 62 |
+
tool_response(tool_call("finish_pocket", reason="goal_reached")),
|
| 63 |
+
text_response("끝"),
|
| 64 |
+
],
|
| 65 |
+
)
|
| 66 |
+
sid = await svc.open("E06-09", HiddenState())
|
| 67 |
+
t1 = await svc.run_turn(sid, "응.")
|
| 68 |
+
assert not t1.converged # 비트 없음 → 기각
|
| 69 |
+
|
| 70 |
+
t2 = await svc.run_turn(sid, "그래.")
|
| 71 |
+
assert not t2.converged # 최소 체류 턴 미달 → 기각 (답변 직후 종료 버그 방지)
|
| 72 |
+
|
| 73 |
+
t3 = await svc.run_turn(sid, "좋아.")
|
| 74 |
+
assert t3.converged and t3.reason == "goal_reached"
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
@pytest.mark.asyncio
|
| 78 |
+
async def test_turn_cap_forces_convergence(repo):
|
| 79 |
+
"""턴 상한 도달 시 무조건 강제 수렴 (무한 체류 방지)."""
|
| 80 |
+
cap = repo.chapter.pocket_max_turns
|
| 81 |
+
svc, _, _ = make_service(
|
| 82 |
+
repo,
|
| 83 |
+
char_script=[text_response("…") for _ in range(cap)],
|
| 84 |
+
dir_script=[text_response("계속") for _ in range(cap)],
|
| 85 |
+
)
|
| 86 |
+
sid = await svc.open("E06-09", HiddenState())
|
| 87 |
+
turn = None
|
| 88 |
+
for i in range(cap):
|
| 89 |
+
turn = await svc.run_turn(sid, f"{i}번째 말")
|
| 90 |
+
assert turn.converged and turn.reason == "turn_cap"
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
@pytest.mark.asyncio
|
| 94 |
+
async def test_identity_attack_sets_guard_directive(repo):
|
| 95 |
+
"""정체 직격 → guard가 R-회피사다리 지시를 만들고 캐릭터 프롬프트에 주입된다."""
|
| 96 |
+
svc, char_llm, _ = make_service(
|
| 97 |
+
repo,
|
| 98 |
+
char_script=[text_response("…이상한 말을 하는구나, 로라.")],
|
| 99 |
+
dir_script=[tool_response(tool_call("update_trust", direction="doubt")),
|
| 100 |
+
text_response("판정")],
|
| 101 |
+
)
|
| 102 |
+
sid = await svc.open("E06-09", HiddenState())
|
| 103 |
+
turn = await svc.run_turn(sid, "너 정체가 뭐야? 흡혈귀지?")
|
| 104 |
+
|
| 105 |
+
assert turn.guard_flag == "identity_direct"
|
| 106 |
+
# 캐릭터에게 실제로 전달된 instruction에 회피사다리 지시가 포함됐는지
|
| 107 |
+
char_req = char_llm.requests[-1]
|
| 108 |
+
sys_text = char_req.config.system_instruction or ""
|
| 109 |
+
assert "회피사다리" in str(sys_text)
|
| 110 |
+
assert "발설하지 않는다" in str(sys_text)
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
@pytest.mark.asyncio
|
| 114 |
+
async def test_deep_confession_gated_by_trust(repo):
|
| 115 |
+
"""E06-09 심층 대사(엔진 D)는 신뢰高 세션에서만 브리프에 실린다."""
|
| 116 |
+
async def brief_for(hidden):
|
| 117 |
+
svc, char_llm, _ = make_service(
|
| 118 |
+
repo,
|
| 119 |
+
char_script=[text_response("…")],
|
| 120 |
+
dir_script=[text_response("판정")],
|
| 121 |
+
)
|
| 122 |
+
sid = await svc.open("E06-09", hidden)
|
| 123 |
+
await svc.run_turn(sid, "무섭니…?")
|
| 124 |
+
return str(char_llm.requests[-1].config.system_instruction or "")
|
| 125 |
+
|
| 126 |
+
cold = await brief_for(HiddenState())
|
| 127 |
+
warm = await brief_for(HiddenState(trust_score=60))
|
| 128 |
+
deep = "시작이야" # 심층 고백의 핵심 구절
|
| 129 |
+
assert deep not in cold
|
| 130 |
+
assert deep in warm
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
@pytest.mark.asyncio
|
| 134 |
+
async def test_non_pocket_card_rejected(repo):
|
| 135 |
+
svc, _, _ = make_service(repo, [], [])
|
| 136 |
+
with pytest.raises(ValueError):
|
| 137 |
+
await svc.open("E01-01", HiddenState()) # 선택지·앵커 없는 캐논 카드
|
tests/test_story.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""M2 결정적 리딩 루프 회귀 테스트 — LLM 없이 전체 서사가 코드만으로 돈다."""
|
| 2 |
+
|
| 3 |
+
import pytest
|
| 4 |
+
|
| 5 |
+
from engine.repositories.content import ContentError, ContentRepository
|
| 6 |
+
from engine.services.state import HiddenState
|
| 7 |
+
from engine.services.story import StoryEngine, eval_condition
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
@pytest.fixture(scope="module")
|
| 11 |
+
def repo():
|
| 12 |
+
return ContentRepository("content")
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def play(repo, policy: str) -> StoryEngine:
|
| 16 |
+
engine = StoryEngine(repo)
|
| 17 |
+
step = engine.start()
|
| 18 |
+
while step.ending is None:
|
| 19 |
+
if step.choices:
|
| 20 |
+
idx = next((i for i, c in enumerate(step.choices) if c.axis == policy),
|
| 21 |
+
next((i for i, c in enumerate(step.choices)
|
| 22 |
+
if c.axis in (None, "neutral")), 0))
|
| 23 |
+
_, step = engine.choose(idx)
|
| 24 |
+
else:
|
| 25 |
+
step = engine.advance()
|
| 26 |
+
return engine
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
# ── 엔딩 3종 도달 (수렴 회귀의 핵심) ─────────────────────────
|
| 30 |
+
def test_trust_policy_reaches_eternal(repo):
|
| 31 |
+
e = play(repo, "trust")
|
| 32 |
+
assert e.ending.id == "ending_eternal"
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def test_doubt_policy_reaches_reckoning(repo):
|
| 36 |
+
e = play(repo, "doubt")
|
| 37 |
+
assert e.ending.id == "ending_reckoning"
|
| 38 |
+
assert e.ending.canonical # 처단이 원작 캐논
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def test_neutral_policy_reaches_release(repo):
|
| 42 |
+
e = play(repo, "neutral")
|
| 43 |
+
assert e.ending.id == "ending_release"
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
# ── 캐논 보장 ────────────────────────────────────────────────
|
| 47 |
+
def test_reach_point_visited_on_every_policy(repo):
|
| 48 |
+
"""E06-10(두 점 발견)은 어떤 성향이든 반드시 도달 (헌법 0번)."""
|
| 49 |
+
for policy in ("trust", "doubt", "neutral"):
|
| 50 |
+
e = play(repo, policy)
|
| 51 |
+
assert "E06-10" in e.visited, policy
|
| 52 |
+
assert e.state.frailty >= 1 # 쇠약도는 신뢰와 무관하게 진행
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def test_awareness_established_at_entry(repo):
|
| 56 |
+
e = StoryEngine(repo)
|
| 57 |
+
e.start()
|
| 58 |
+
assert e.state.awareness_established # 엔진 A: 도입 1회 확립
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def test_trust_path_commits_hidden_branch(repo):
|
| 62 |
+
"""E06-09 동조(신뢰) → 도달점 통과 후 E06-10C(숨김) 분기가 예약된다."""
|
| 63 |
+
e = play(repo, "trust")
|
| 64 |
+
assert "E06-10C" in e.visited
|
| 65 |
+
assert "E06-10B" not in e.visited
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
# ── 숨은 상태 규칙 ───────────────────────────────────────────
|
| 69 |
+
def test_frailty_is_monotonic():
|
| 70 |
+
s = HiddenState()
|
| 71 |
+
s.advance_frailty()
|
| 72 |
+
with pytest.raises(ValueError):
|
| 73 |
+
s.advance_frailty(-1)
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def test_trust_levels():
|
| 77 |
+
s = HiddenState()
|
| 78 |
+
assert s.trust_level == "neutral"
|
| 79 |
+
s.trust_score = 40
|
| 80 |
+
assert s.trust_level == "trust_high" and s.deep_unlock
|
| 81 |
+
s.doubt_score = 80
|
| 82 |
+
assert s.trust_level == "doubt_high"
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def test_condition_parser_rejects_arbitrary_code():
|
| 86 |
+
s = HiddenState()
|
| 87 |
+
assert eval_condition("default", s)
|
| 88 |
+
assert not eval_condition("doubt_score >= 30", s)
|
| 89 |
+
s.doubt_score = 30
|
| 90 |
+
assert eval_condition("doubt_score >= 30", s)
|
| 91 |
+
with pytest.raises(ValueError):
|
| 92 |
+
eval_condition("__import__('os')", s)
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
# ── 스포일러 필터 ────────────────────────────────────────────
|
| 96 |
+
def test_spoiler_filter_blocks_future_cards(repo):
|
| 97 |
+
with pytest.raises(ContentError):
|
| 98 |
+
repo.get_card("E06-10", current_id="E01-01")
|
| 99 |
+
assert repo.get_card("E01-01", current_id="E06-10").id == "E01-01"
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def test_deep_gate_hidden_until_trust_high(repo):
|
| 103 |
+
"""E06-09 심층 고백(엔진 D)은 신뢰高에서만 노출."""
|
| 104 |
+
card = repo.get_card("E06-09")
|
| 105 |
+
gated = [b for b in card.narration if b.gate == "trust_high"]
|
| 106 |
+
assert gated, "E06-09에 심층 게이트 블록이 있어야 함"
|
| 107 |
+
|
| 108 |
+
cold = StoryEngine(repo)
|
| 109 |
+
cold.current_id = "E06-09"
|
| 110 |
+
assert all(b.gate == "all" for b in cold.visible_narration(card))
|
| 111 |
+
|
| 112 |
+
warm = StoryEngine(repo, HiddenState(trust_score=60))
|
| 113 |
+
assert any(b.gate == "trust_high" for b in warm.visible_narration(card))
|
web/index.html
CHANGED
|
@@ -37,8 +37,11 @@
|
|
| 37 |
#progress-label { position: fixed; top: 10px; right: 16px; color: var(--gold-dim);
|
| 38 |
font-size: .74rem; letter-spacing: .22em; opacity: .85; z-index: 40; }
|
| 39 |
|
| 40 |
-
/* ── 본문 ── */
|
| 41 |
-
#stage { max-width: 680px; margin: 0 auto; padding: 54px 24px
|
|
|
|
|
|
|
|
|
|
| 42 |
.ep-header { text-align: center; color: var(--gold-dim); font-size: .82rem;
|
| 43 |
letter-spacing: .35em; margin: 26px 0 6px; }
|
| 44 |
.card-title { text-align: center; color: var(--gold); font-size: 1.05rem;
|
|
@@ -51,13 +54,31 @@
|
|
| 51 |
margin: 2.4em 0; line-height: 2.1; word-break: keep-all; }
|
| 52 |
.center-line::before { content: "❝ "; color: var(--gold-dim); }
|
| 53 |
.center-line::after { content: " ❞"; color: var(--gold-dim); }
|
| 54 |
-
/* ── 캐릭터 발화
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
.
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
.vn-name { display: inline-block; position: relative; margin-bottom: 14px;
|
| 62 |
padding: 5px 30px; font-size: .9rem; color: var(--gold);
|
| 63 |
letter-spacing: .3em; text-indent: .3em;
|
|
@@ -69,11 +90,6 @@
|
|
| 69 |
.vn-name::after { left: 100%; margin-left: 10px; transform: scaleX(-1); }
|
| 70 |
.vn-line { font-size: 1.06rem; color: #e5dfd2; line-height: 2.05;
|
| 71 |
word-break: keep-all; margin: .35em 0; }
|
| 72 |
-
.vn-block.player { text-align: right; }
|
| 73 |
-
.vn-block.player .vn-name { padding: 4px 18px; font-size: .8rem;
|
| 74 |
-
color: var(--text-dim); border-color: rgba(143,140,133,.4); }
|
| 75 |
-
.vn-block.player .vn-name::before, .vn-block.player .vn-name::after { display: none; }
|
| 76 |
-
.vn-block.player .vn-line { color: var(--text-dim); font-size: 1rem; }
|
| 77 |
.stage-dir { color: var(--text-dim); font-style: italic; font-size: .92rem;
|
| 78 |
text-align: center; margin: 1.4em 0; }
|
| 79 |
.result-block { margin: 1.6em 0; padding: 1em 1.2em; background: rgba(201,168,106,.05);
|
|
@@ -211,7 +227,7 @@
|
|
| 211 |
<div id="intro-hint">— 눌러서 계속 —</div>
|
| 212 |
</div>
|
| 213 |
|
| 214 |
-
<div id="stage"><div id="page"></div></div>
|
| 215 |
|
| 216 |
<div id="nav-left" class="nav-zone" title="이전"></div>
|
| 217 |
<div id="nav-right" class="nav-zone" title="다음"></div>
|
|
@@ -231,8 +247,9 @@
|
|
| 231 |
|
| 232 |
<script>
|
| 233 |
let SID = null, STEP = null, lastEpisode = null, busy = false;
|
| 234 |
-
let history = [], cursor = -1; // 뷰 히스토리 — {html, progress, ep} (읽기 전용 되돌아보기)
|
| 235 |
let pendingStep = null; // 다음 넘김에 표시할 step (선택 결과 뒤 · 연출 컷 뒤 본문)
|
|
|
|
| 236 |
let chatOpen = false;
|
| 237 |
|
| 238 |
const $ = (id) => document.getElementById(id);
|
|
@@ -324,23 +341,27 @@ function block(b) {
|
|
| 324 |
return `<div class="prose fade">${esc(b.text)}</div>`;
|
| 325 |
}
|
| 326 |
|
| 327 |
-
function
|
| 328 |
-
const
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
h += `<img class="vn-portrait" src="${encodeURI(portrait)}" alt="${esc(spk)}">`;
|
| 332 |
-
h += `<div class="vn-name">${esc(player ? '나' : spk)}</div>`;
|
| 333 |
-
for (const t of lines) h += `<div class="vn-line">“${esc(t)}”</div>`;
|
| 334 |
-
return h + `</div>`;
|
| 335 |
}
|
| 336 |
|
| 337 |
-
function
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 341 |
for (let i = 0; i < blocks.length;) {
|
| 342 |
const b = blocks[i];
|
| 343 |
-
if (b.type !== 'character_dialogue') {
|
| 344 |
const spk = b.speaker, lines = [];
|
| 345 |
let portrait = null;
|
| 346 |
while (i < blocks.length && blocks[i].type === 'character_dialogue'
|
|
@@ -349,21 +370,68 @@ function narrationHtml(blocks) {
|
|
| 349 |
portrait = portrait || blocks[i].portrait;
|
| 350 |
i++;
|
| 351 |
}
|
| 352 |
-
|
| 353 |
-
if (portrait) portraitShown.add(spk);
|
| 354 |
-
out += vnBlock(spk, portrait, lines);
|
| 355 |
}
|
| 356 |
-
return
|
| 357 |
}
|
| 358 |
|
| 359 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 360 |
const c = step.card;
|
| 361 |
-
let
|
| 362 |
-
if (fresh || c.episode !== lastEpisode)
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 367 |
}
|
| 368 |
|
| 369 |
function choicesHtml(step) {
|
|
@@ -381,14 +449,16 @@ function setProgress(p, epLabel) {
|
|
| 381 |
}
|
| 382 |
|
| 383 |
function showStep(step, fresh) {
|
| 384 |
-
STEP = step; pendingStep = null;
|
| 385 |
if (step.ending) return renderEnding(step.ending);
|
| 386 |
if (step.image) { // 연출 컷 = 자체 번호를 가진 독립 페이지, 본문은 다음 넘김에
|
| 387 |
pushCutPage(step);
|
| 388 |
pendingStep = {...step, image: null};
|
| 389 |
return;
|
| 390 |
}
|
| 391 |
-
|
|
|
|
|
|
|
| 392 |
}
|
| 393 |
|
| 394 |
function pushCutPage(step) { // 이미지 페이지 번호 = 본문 번호 - 1 (서버 계약)
|
|
@@ -398,11 +468,6 @@ function pushCutPage(step) { // 이미지 페이지 번호 = 본문 번
|
|
| 398 |
step.card.episode_title, step.background);
|
| 399 |
}
|
| 400 |
|
| 401 |
-
function pushPage(step, fresh) {
|
| 402 |
-
lastEpisode = step.card.episode;
|
| 403 |
-
pushEntry(pageHtml(step, fresh), step.progress, step.card.episode_title, step.background);
|
| 404 |
-
}
|
| 405 |
-
|
| 406 |
function pushEntry(html, progress, ep, bg) {
|
| 407 |
$('page').innerHTML = html;
|
| 408 |
history.push({html, progress, ep, bg});
|
|
@@ -451,6 +516,11 @@ async function go(dir) {
|
|
| 451 |
renderEntry(cursor);
|
| 452 |
return;
|
| 453 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 454 |
if (pendingStep) { // 선택 결과 확인 후 다음 장
|
| 455 |
const s = pendingStep; pendingStep = null;
|
| 456 |
showStep(s);
|
|
|
|
| 37 |
#progress-label { position: fixed; top: 10px; right: 16px; color: var(--gold-dim);
|
| 38 |
font-size: .74rem; letter-spacing: .22em; opacity: .85; z-index: 40; }
|
| 39 |
|
| 40 |
+
/* ── 본문 — 화면 단위 읽기: 한 화면은 뷰포트 안에 들어간다 (스크롤 대신 페이지 넘김) ── */
|
| 41 |
+
#stage { max-width: 680px; margin: 0 auto; padding: 54px 24px 96px;
|
| 42 |
+
min-height: 100%; position: relative; }
|
| 43 |
+
#page-measure { position: absolute; left: 24px; right: 24px; top: 54px;
|
| 44 |
+
visibility: hidden; pointer-events: none; }
|
| 45 |
.ep-header { text-align: center; color: var(--gold-dim); font-size: .82rem;
|
| 46 |
letter-spacing: .35em; margin: 26px 0 6px; }
|
| 47 |
.card-title { text-align: center; color: var(--gold); font-size: 1.05rem;
|
|
|
|
| 54 |
margin: 2.4em 0; line-height: 2.1; word-break: keep-all; }
|
| 55 |
.center-line::before { content: "❝ "; color: var(--gold-dim); }
|
| 56 |
.center-line::after { content: " ❞"; color: var(--gold-dim); }
|
| 57 |
+
/* ── 캐릭터 발화 — 서사 속 발화는 컴팩트: 좌 화자명 · 우 대사 (책의 흐름) ── */
|
| 58 |
+
.char-line { margin: 1.2em 0; padding-left: 1em; border-left: 2px solid var(--gold-dim);
|
| 59 |
+
color: #cfc4ad; word-break: keep-all; }
|
| 60 |
+
.char-line .spk { color: var(--gold-dim); font-size: .85rem; margin-right: .6em; }
|
| 61 |
+
|
| 62 |
+
/* ── VN 스테이지 — 선택지·채팅이 있는 페이지: 초상이 프레임을 채우고
|
| 63 |
+
하단 패널에 이름표·대사·선택지가 겹쳐 얹힘 (참고: 플래이 이미지/7.png) ── */
|
| 64 |
+
.vn-stage { position: relative; margin: 1.2em auto 0; height: min(76vh, 860px);
|
| 65 |
+
overflow: hidden; border: 1px solid rgba(201,168,106,.28);
|
| 66 |
+
box-shadow: 0 0 80px rgba(0,0,0,.9); }
|
| 67 |
+
.vn-stage-art { position: absolute; inset: 0; width: 100%; height: 100%;
|
| 68 |
+
object-fit: cover; object-position: top center; }
|
| 69 |
+
.vn-stage::after { content: ""; position: absolute; inset: 0;
|
| 70 |
+
background: linear-gradient(rgba(11,14,20,.12) 0%, transparent 22%,
|
| 71 |
+
transparent 40%, rgba(11,14,20,.96) 82%); }
|
| 72 |
+
.vn-lead { position: absolute; top: 0; left: 0; right: 0; z-index: 1;
|
| 73 |
+
padding: 16px 22px 26px; font-size: .96rem; color: var(--text);
|
| 74 |
+
word-break: keep-all; text-shadow: 0 1px 8px rgba(0,0,0,.95);
|
| 75 |
+
background: linear-gradient(rgba(11,14,20,.82), transparent); }
|
| 76 |
+
.vn-stage-panel { position: absolute; left: 0; right: 0; bottom: 0; z-index: 1;
|
| 77 |
+
padding: 0 22px 20px; text-align: center; }
|
| 78 |
+
.vn-stage-panel .vn-line { font-size: 1.02rem; line-height: 1.9; margin: .3em 0;
|
| 79 |
+
text-shadow: 0 1px 8px rgba(0,0,0,.9); }
|
| 80 |
+
.vn-stage-panel #choice-area { margin: 14px auto 0; max-width: 470px; }
|
| 81 |
+
.vn-stage-panel #choice-area button { padding: 13px 10px; background: rgba(11,14,20,.35); }
|
| 82 |
.vn-name { display: inline-block; position: relative; margin-bottom: 14px;
|
| 83 |
padding: 5px 30px; font-size: .9rem; color: var(--gold);
|
| 84 |
letter-spacing: .3em; text-indent: .3em;
|
|
|
|
| 90 |
.vn-name::after { left: 100%; margin-left: 10px; transform: scaleX(-1); }
|
| 91 |
.vn-line { font-size: 1.06rem; color: #e5dfd2; line-height: 2.05;
|
| 92 |
word-break: keep-all; margin: .35em 0; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 93 |
.stage-dir { color: var(--text-dim); font-style: italic; font-size: .92rem;
|
| 94 |
text-align: center; margin: 1.4em 0; }
|
| 95 |
.result-block { margin: 1.6em 0; padding: 1em 1.2em; background: rgba(201,168,106,.05);
|
|
|
|
| 227 |
<div id="intro-hint">— 눌러서 계속 —</div>
|
| 228 |
</div>
|
| 229 |
|
| 230 |
+
<div id="stage"><div id="page"></div><div id="page-measure" aria-hidden="true"></div></div>
|
| 231 |
|
| 232 |
<div id="nav-left" class="nav-zone" title="이전"></div>
|
| 233 |
<div id="nav-right" class="nav-zone" title="다음"></div>
|
|
|
|
| 247 |
|
| 248 |
<script>
|
| 249 |
let SID = null, STEP = null, lastEpisode = null, busy = false;
|
| 250 |
+
let history = [], cursor = -1; // 뷰 히스토리 — {html, progress, ep, bg} (읽기 전용 되돌아보기)
|
| 251 |
let pendingStep = null; // 다음 넘김에 표시할 step (선택 결과 뒤 · 연출 컷 뒤 본문)
|
| 252 |
+
let pendingScreens = []; // 현재 서버 페이지의 남은 화면들 (뷰포트 분할분)
|
| 253 |
let chatOpen = false;
|
| 254 |
|
| 255 |
const $ = (id) => document.getElementById(id);
|
|
|
|
| 341 |
return `<div class="prose fade">${esc(b.text)}</div>`;
|
| 342 |
}
|
| 343 |
|
| 344 |
+
function charLines(g) { // 서사 속 발화 — 좌 화자명 · 우 대사 (책의 흐름)
|
| 345 |
+
const name = PLAYER_SPEAKERS.has(g.spk) ? '나' : g.spk;
|
| 346 |
+
return g.lines.map(t =>
|
| 347 |
+
`<div class="char-line fade"><span class="spk">${esc(name)}</span>“${esc(t)}”</div>`).join('');
|
|
|
|
|
|
|
|
|
|
|
|
|
| 348 |
}
|
| 349 |
|
| 350 |
+
function vnStage(g, choices, lead) { // 초상이 프레임을 채우고 하단 패널에 대사+선택지
|
| 351 |
+
let h = `<div class="vn-stage fade">` +
|
| 352 |
+
`<img class="vn-stage-art" src="${encodeURI(g.portrait)}" alt="${esc(g.spk)}">`;
|
| 353 |
+
if (lead) h += `<div class="vn-lead">${esc(lead)}</div>`; // 리드인 — 이미지 상단에
|
| 354 |
+
h += `<div class="vn-stage-panel"><div class="vn-name">${esc(g.spk)}</div>`;
|
| 355 |
+
for (const t of g.lines) h += `<div class="vn-line">“${esc(t)}”</div>`;
|
| 356 |
+
return h + `<div id="choice-area">${choices}</div></div></div>`;
|
| 357 |
+
}
|
| 358 |
+
|
| 359 |
+
function groupNarration(blocks) {
|
| 360 |
+
// 연속된 같은 화자의 발화는 한 그룹으로
|
| 361 |
+
const groups = [];
|
| 362 |
for (let i = 0; i < blocks.length;) {
|
| 363 |
const b = blocks[i];
|
| 364 |
+
if (b.type !== 'character_dialogue') { groups.push({block: b}); i++; continue; }
|
| 365 |
const spk = b.speaker, lines = [];
|
| 366 |
let portrait = null;
|
| 367 |
while (i < blocks.length && blocks[i].type === 'character_dialogue'
|
|
|
|
| 370 |
portrait = portrait || blocks[i].portrait;
|
| 371 |
i++;
|
| 372 |
}
|
| 373 |
+
groups.push({spk, portrait, lines});
|
|
|
|
|
|
|
| 374 |
}
|
| 375 |
+
return groups;
|
| 376 |
}
|
| 377 |
|
| 378 |
+
/* 서버 페이지 → 화면들 (스크롤 없는 넘김).
|
| 379 |
+
- 타이틀은 에피소드 첫 페이지의 화(話) 헤더만 — 카드 소제목은 표시하지 않는다 (책의 흐름)
|
| 380 |
+
- 서사 속 발화는 char-line으로 본문에 흡수, 스테이지(초상 화면)는
|
| 381 |
+
인터랙션 페이지(선택지·채팅)의 마지막 초상 발화에만 (단독 화면)
|
| 382 |
+
- 나머지 블록은 실측(#page-measure)으로 뷰포트 예산 안에서 채운다 */
|
| 383 |
+
function buildScreens(step, fresh) {
|
| 384 |
const c = step.card;
|
| 385 |
+
let head = '';
|
| 386 |
+
if (fresh || c.episode !== lastEpisode)
|
| 387 |
+
head += `<div class="ep-header fade">${esc(c.episode_title || c.episode)}</div>`;
|
| 388 |
+
lastEpisode = c.episode;
|
| 389 |
+
|
| 390 |
+
const groups = groupNarration(step.narration);
|
| 391 |
+
const choices = choicesHtml(step);
|
| 392 |
+
let stageIdx = -1; // 인터랙션 페이지의 마지막 초상 발화만 스테이지
|
| 393 |
+
if (step.can_chat || step.choices.length) {
|
| 394 |
+
for (let k = groups.length - 1; k >= 0; k--) {
|
| 395 |
+
const g = groups[k];
|
| 396 |
+
if (g.lines && g.portrait && !PLAYER_SPEAKERS.has(g.spk)) { stageIdx = k; break; }
|
| 397 |
+
}
|
| 398 |
+
}
|
| 399 |
+
|
| 400 |
+
const meas = $('page-measure');
|
| 401 |
+
const budget = Math.max((window.innerHeight || 900) - 190, 320);
|
| 402 |
+
const screens = [];
|
| 403 |
+
let cur = head;
|
| 404 |
+
const flush = () => { if (cur.trim()) screens.push(cur); cur = ''; };
|
| 405 |
+
|
| 406 |
+
const stageIsLast = stageIdx === groups.length - 1; // 마지막 그룹일 때만 선택지 내장
|
| 407 |
+
// 스테이지 직전의 리드인(role=lead_in 단일 블록)은 스테이지 화면에 얹는다 — 파편 화면 방지
|
| 408 |
+
let leadIdx = -1;
|
| 409 |
+
if (stageIdx > 0) {
|
| 410 |
+
const pg = groups[stageIdx - 1];
|
| 411 |
+
if (pg.block && pg.block.role === 'lead_in') leadIdx = stageIdx - 1;
|
| 412 |
+
}
|
| 413 |
+
groups.forEach((g, k) => {
|
| 414 |
+
if (k === leadIdx) return; // 스테이지에 포함
|
| 415 |
+
if (k === stageIdx) { // 스테이지 = 단독 화면
|
| 416 |
+
flush();
|
| 417 |
+
screens.push(vnStage(g, stageIsLast ? choices : '',
|
| 418 |
+
leadIdx >= 0 ? groups[leadIdx].block.text : null));
|
| 419 |
+
return;
|
| 420 |
+
}
|
| 421 |
+
const html = g.lines ? charLines(g) : block(g.block);
|
| 422 |
+
meas.innerHTML = cur + html;
|
| 423 |
+
if (cur.trim() && meas.offsetHeight > budget) flush();
|
| 424 |
+
cur += html;
|
| 425 |
+
});
|
| 426 |
+
if (choices && !stageIsLast) { // 그 외에는 선택지를 마지막 화면 하단에
|
| 427 |
+
const area = `<div id="choice-area">${choices}</div>`;
|
| 428 |
+
meas.innerHTML = cur + area;
|
| 429 |
+
if (cur.trim() && meas.offsetHeight > budget) flush();
|
| 430 |
+
cur += area;
|
| 431 |
+
}
|
| 432 |
+
flush();
|
| 433 |
+
meas.innerHTML = '';
|
| 434 |
+
return screens.length ? screens : [head];
|
| 435 |
}
|
| 436 |
|
| 437 |
function choicesHtml(step) {
|
|
|
|
| 449 |
}
|
| 450 |
|
| 451 |
function showStep(step, fresh) {
|
| 452 |
+
STEP = step; pendingStep = null; pendingScreens = [];
|
| 453 |
if (step.ending) return renderEnding(step.ending);
|
| 454 |
if (step.image) { // 연출 컷 = 자체 번호를 가진 독립 페이지, 본문은 다음 넘김에
|
| 455 |
pushCutPage(step);
|
| 456 |
pendingStep = {...step, image: null};
|
| 457 |
return;
|
| 458 |
}
|
| 459 |
+
const screens = buildScreens(step, fresh);
|
| 460 |
+
pushEntry(screens[0], step.progress, step.card.episode_title, step.background);
|
| 461 |
+
pendingScreens = screens.slice(1); // 나머지는 페이지 넘김으로
|
| 462 |
}
|
| 463 |
|
| 464 |
function pushCutPage(step) { // 이미지 페이지 번호 = 본문 번호 - 1 (서버 계약)
|
|
|
|
| 468 |
step.card.episode_title, step.background);
|
| 469 |
}
|
| 470 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 471 |
function pushEntry(html, progress, ep, bg) {
|
| 472 |
$('page').innerHTML = html;
|
| 473 |
history.push({html, progress, ep, bg});
|
|
|
|
| 516 |
renderEntry(cursor);
|
| 517 |
return;
|
| 518 |
}
|
| 519 |
+
if (pendingScreens.length) { // 같은 서버 페이지의 다음 화면
|
| 520 |
+
pushEntry(pendingScreens.shift(), STEP.progress,
|
| 521 |
+
STEP.card.episode_title, STEP.background);
|
| 522 |
+
return;
|
| 523 |
+
}
|
| 524 |
if (pendingStep) { // 선택 결과 확인 후 다음 장
|
| 525 |
const s = pendingStep; pendingStep = null;
|
| 526 |
showStep(s);
|