# CLAUDE.md — Interactive Novel Engine Backend ## 프로젝트 개요 원작 고전 소설(카르밀라)을 온톨로지 기반으로 형식화하여 "책을 읽는 느낌 + 핵심 씬에서의 상호작용"을 제공하는 인터랙티브 노벨 엔진. Google ADK 기반 파이프라인이 유저 입력을 받아 가드 → 캐릭터 응답 → 디렉터(수렴 판단) 순으로 처리하고, 원작·온톨로지 검색은 GraphRAG 계층이 담당. **최상위 가치: 원작 충실.** 원작의 톤과 세계관이 깨지는 순간 실패다. LLM의 자유도는 기능이 아니라 리스크로 취급한다. ## 아키텍처 3층 구조 ``` [밑단] 온톨로지/상태 설계 문서 → YAML v1.0 (대표님 산출물) → 엔진이 파싱 [중간] GraphRAG: 온톨로지 YAML을 Neo4j에 적재 + 원문 발췌 벡터 검색 [앞단] ADK 에이전트 파이프라인 (guard → character → director) ``` - 온톨로지의 **내용**은 대표님 소유, YAML **스키마(형식)**는 개발팀 소유. 스키마 변경은 양쪽 합의로만. - 그래프는 원문에서 LLM이 자동 추출하지 않는다. 큐레이션된 YAML을 적재하는 것이 유일한 경로. ## 작업 원칙 ### 1. 논의 → 설계 → 계획 → 구현 → 리뷰 코드를 먼저 작성하지 않는다. 반드시 이 순서를 따른다: 1. **논의**: 요구사항을 정확히 이해하고 질문한다 2. **설계**: 2-3개 접근법을 비교하고 추천한다 3. **계획**: 구체적인 파일 목록과 변경 사항을 문서화한다 4. **구현**: 계획대로만 구현한다. 범위를 벗어나지 않는다 5. **리뷰**: 구현 결과를 spec과 대조하여 검증한다 6. **갱신**: 커밋 및 코드 구현이 완료되면 해당 부분을 꼼꼼하게 단계별로 메모리에 반영 및 갱신하세요 이 사이클을 충분히 반복한다. 급하다고 건너뛰지 않는다. ### 2. 기존 코드의 일관성을 지킨다 - 새 코드는 기존 패턴을 따른다. 더 나은 패턴이 있어도 혼자 바꾸지 않는다 - 폴더 구조, 네이밍, import 스타일을 기존 코드에서 먼저 확인한다 - "개선"이라는 이름으로 기존 컨벤션을 무시하지 않는다 ### 3. Google ADK 공식 패턴을 따른다 - ADK가 제공하는 기능(LlmAgent, ParallelAgent, SequentialAgent, LoopAgent, AgentTool, FunctionTool)으로 해결한다 - Python으로 ADK를 우회하거나 대체하는 코드를 작성하지 않는다 - 프롬프트로 해결하려 하기 전에, 에이전트 구조(계층, 타입, 조합)로 해결할 수 있는지 먼저 검토한다 - **조금이라도 의심되면 바로 ADK 공식문서를 확인한다**: https://google.github.io/adk-docs/ ### 4. 확인 없이 추측하지 않는다 - 파일을 수정하기 전에 반드시 읽는다 - ADK API를 사용할 때 기억에 의존하지 않고 문서나 소스를 확인한다 - Docker 로그, grep 결과 등 실제 증거를 기반으로 판단한다 - 원작 원문·온톨로지 내용을 기억으로 인용하지 않는다. 반드시 content/ 또는 그래프에서 조회한다 ### 5. 서사 진행은 코드가, 대사는 LLM이 - 챕터/씬 전환, 분기 조건, 수렴 판정의 **최종 결정은 결정적 코드**(상태머신 + director 출력의 구조화된 검증)가 내린다 - LLM은 "지금 이 씬 안에서 이 캐릭터로 응답"하는 좁은 역할만 맡는다 - LLM 출력만 믿고 state를 전이시키지 않는다. 전이 조건은 반드시 코드에서 재검증한다 --- ## 핵심 도메인 규칙 ### 원작 충실 (Canon Fidelity) - 캐릭터가 원작에 없는 사실을 단정하는 응답은 결함이다. 프롬프트에 주입된 온톨로지·원문 발췌 범위 안에서만 말하게 한다 - 유저가 세계관을 벗어난 행동을 하면 director가 세계관 내 화법으로 되돌린다. 메타 발화("저는 AI라서...")는 절대 금지 - 문체는 `content/style/` 문체연출 가이드가 기준. style_critic 루프가 검수한다 ### 스포일러 스코프 (매우 중요) - 모든 원문 청크, 그래프 노드/엣지, 온톨로지 항목에는 `state_id`(서사 진행 단위)가 붙는다 - **모든 검색은 `state_id <= 현재 state` 필터를 강제한다.** 필터 없는 검색 코드는 리뷰에서 반려 - 지식 상태: "state N 시점에 이 캐릭터가 아는 것"은 온톨로지의 지식 상태 항목이 권위. 캐릭터 프롬프트에는 해당 시점 지식만 주입한다 ### 수렴 (Convergence) - 씬마다 YAML에 정의된 목표 비트(goal beats)와 수렴 조건(exit condition)이 있다 - director는 매 턴 "목표 비트 달성 여부 / 이탈 정도 / 수렴 시점 도달 여부"를 구조화 출력으로 판단한다 - 최대 턴 수를 초과하면 director가 강제 수렴 내레이션으로 씬을 닫는다 (무한 체류 방지) --- ## 에이전트 폴더 규칙 ``` 에이전트 = 폴더 + agent.py ← 하위 에이전트가 있는 계층형 하위 에이전트 = sub_agents/ 폴더 ← 부모-자식 계층 같은 뎁스 병렬 에이전트 = agents/ 파일 ← 자체 하위 없는 단순 에이전트 (가드 등) 도구 = tools/ 안에 1파일 = 1함수 instruction 10줄 초과 = instructions/ 폴더로 분리 팩토리 함수 = 조립만 담당 (클래스 정의/DB 헬퍼 포함 금지) ``` ### sub_agents/ vs agents/ 구분 - `sub_agents/X/agent.py` 패턴은 **X가 추가로 하위 계층을 가질 때** 사용. 예: `sub_agents/director/sub_agents/style_critic/...` - `agents/X.py` 패턴은 **X가 자체 하위 없이 단일 BaseAgent/LlmAgent로 끝날 때** 사용. 팩토리 함수(`create_X()`)와 클래스를 같은 파일에 모아둠. 예: `engine/agents/input_guard.py`, `spoiler_guard.py`, `scene_context_loader.py` ### 팩토리 파일 책임 분리 `factory.py`는 **조립만** 담당. 에이전트 클래스 정의·DB 헬퍼 함수는 `agents/*.py`나 `sub_agents/*/agent.py`에 위치. 팩토리는 그것들을 import해서 `SequentialAgent(sub_agents=[create_guard(scene_id), create_character(scene_id), ...])` 형태로 엮는 역할만. ### 엔진 구조 ``` engine/ ├── agent.py ← 루트 에이전트 (턴 파이프라인) ├── app.py ← ADK App + Runner + Plugin 등록 ├── model.py ← LLM 모델 설정 (env에서 읽음) ├── router.py ← FastAPI 엔드포인트 (HTTP만 담당) ├── instructions/ ← instruction 텍스트 (캐릭터/디렉터/가드) ├── callbacks/ ← 에이전트별 콜백 ├── plugins/ ← ADK Plugin (횡종단 관심사) │ └── logging.py ← 턴 로깅 (입력/컨텍스트/응답/가드발동/수렴) — 연구 데이터 겸용 ├── services/ ← 비즈니스 로직 │ ├── story.py ← 카드 진행 상태머신 (canon 읽기/next 전이/엔딩 판정) │ ├── pocket.py ← 개입 포켓: ADK 세션 관리 + run_turn() + 수렴 코드 재검증 │ ├── state.py ← 숨은 상태 (신뢰축/쇠약도/앎) 커밋 │ └── retrieval.py ← 검색 오케스트레이션 (스포일러 필터 강제 지점, GraphRAG는 2단계) ├── repositories/ ← 데이터 접근 │ ├── ontology.py ← 온톨로지 조회 인터페이스 (구현: yaml_direct / neo4j) │ ├── manuscript.py ← 원문 청크 + 벡터 검색 │ └── playlog.py ← 턴 로그 저장 ├── tools/ ← 1파일 = 1도구함수 │ ├── retrieval/ ← query_ontology, search_excerpt │ └── state/ ← set_flag, update_affinity, mark_beat ├── sub_agents/ ← 에이전트 트리 │ ├── character/ ← 캐릭터 응답 (인물 시트 + 씬 브리프 + 발췌 주입) │ ├── director/ ← 비트 달성/이탈/수렴 판단 (구조화 출력) │ └── style_critic/ ← LoopAgent (generator + critic) 문체 검수 ├── schemas/ ← Pydantic: 콘텐츠 3레이어 스키마 + validate CLI └── content/ ← (읽기 전용) 엔진의 유일한 서사 입력 — 원천은 비트카드 MD(대표님 소유) ├── beatcards/ ← 비트카드 YAML (scripts/beatcard_convert.py 산출물, E01~) ├── chapters/ ← 챕터 메타 (entry/exit beats, ending_rules, R카드 등록) ├── characters.yaml ← 인물 정체성 시트 (ENOS, 지식경계, kb_query 훅) ├── style/ ← 문체연출 가이드 └── narrative/ ← 서사/핸드오프 문서 ``` ### ADK App + Plugin 규칙 - `app.py`에서 `App` 객체 + `Runner`를 정의한다 - 횡종단 관심사(로깅, 모니터링 등)는 Plugin으로 분리한다 (`plugins/`) - Runner는 `App` 기반으로 초기화한다 (`Runner(app=app, ...)`) - agent를 Runner에 직접 전달하지 않는다 (레거시 패턴) - 스캐폴드 파일(미구현 도구, 빈 콜백, 빈 instruction)은 삭제하지 않는다 - Claude Code가 ADK 패턴을 벗어나지 않도록 하는 가드레일 역할 - 호출 흐름: Agent → Tool(FunctionTool) → Service → Repository → 데이터(YAML/Neo4j/DB) --- ## 콘텐츠 파이프라인 (A안 아키텍처 — 2026-07-03 확정) ### 콘텐츠 3레이어 | 레이어 | 파일 | 내용 | 대응 스키마 | |------|------|------|------| | 정체성 (불변) | `content/characters.yaml` | 인물 시트(ENOS), player=laura, 지식경계, kb_query 훅 | `schemas/character.py` | | 챕터 메타 | `content/chapters/*.yaml` | entry/exit beats, beat_range, 엔딩 규칙, R카드 등록 | `schemas/chapter.py` | | 내러티브 | `content/beatcards/E##/*.yaml` | 카드: narration 블록/choices/next/canon/복선 태그/연출 | `schemas/beatcard.py` | - **콘텐츠 원천(권위)은 비트카드 MD**(`카르밀라/.../비트카드/`, 대표님 소유). 변환 YAML은 파생물이며 `scripts/beatcard_convert.py`(개발팀 소유)로만 생성 - 콘텐츠 내용 수정은 개발팀이 하지 않는다. 스키마 위반 발견 시 검증 리포트로 대표님께 전달 - `python -m engine.schemas.validate content/` 가 통과하지 않는 YAML은 적재하지 않는다 - MVP의 서사 진행 단위는 비트카드 순서: 스포일러 필터의 `state_id`는 `beat_id ≤ 현재`로 해석한다 ### 숨은 상태 모델 (P0 4종 엔진 명세 준거) - **신뢰축**(의심↔신뢰 시소) · **쇠약도**(단조 증가, 신뢰와 독립 — 핵심 아이러니) · **앎**(유저, 도입 1회 확립 후 불변) - 숫자·게이지·시스템 메시지를 유저에게 절대 노출하지 않는다. 상태는 내레이션 톤·카르밀라의 결·해금으로만 드러낸다 - 엔딩 판정은 챕터 메타의 `ending_rules` 조건식을 코드로 평가한다 (LLM 판정 금지) ### GraphRAG 계층 (2단계 — MVP 제외) - MVP는 pre_kb 상태: `content/`의 `kb_query` 훅(`source: kb_query, status: pending`)은 채우지 않고 그대로 둔다 - MVP 콘텐츠 로더는 `repositories/content.py`(YAML 메모리 로드). 이후 GraphRAG 도입 시 `kb_query` 훅을 채우는 구현으로 교체. 상위 계층은 구현을 몰라야 한다 (ablation 실험 요건) - Neo4j 적재 시 LLM 자동 추출 KG 빌더(SimpleKGPipeline 류)는 사용 금지 — 큐레이션 콘텐츠와 충돌 - 독자 상호작용 데이터의 그래프화도 2단계. MVP에서는 `playlog` 스키마 설계까지만 --- ## ADK 핵심 규칙 ### Instruction | 종류 | 용도 | 주의 | |------|------|------| | `instruction` | 동적. `{var}`로 state 변수 치환 | `{}`는 state 변수로 파싱됨. 예시 텍스트에 `{}` 쓰지 말 것 | | `global_instruction` | 전체 sub_agent에 전파 | 의도적으로 사용할 때만 채울 것 | | `static_instruction` | 고정. 멀티모달 지원 | 캐릭터 시트처럼 씬 내 불변 컨텍스트에 검토 | - 원작 발췌·온톨로지 컨텍스트를 instruction에 넣을 때 원문에 `{`, `}`가 포함될 수 있으므로 주입 전 이스케이프 처리를 거친다 (전처리 유틸 필수) ### Callback (8종) `before_agent`, `after_agent`, `before_model`, `after_model`, `before_tool`, `after_tool`, `on_model_error`, `on_tool_error` - `before_model`: 스포일러 필터 최종 방어선 (주입 컨텍스트의 state_id 검사) - `after_model`: 턴 로깅 훅 ### 에이전트 타입 | 타입 | 용도 | 예시 | |------|------|------| | `LlmAgent` | LLM이 판단/도구 호출 | character, director | | `ParallelAgent` | 하위 에이전트 병렬 실행 | (필요 시) 다중 검색 | | `SequentialAgent` | 하위 에이전트 순차 실행 | turn_pipeline (guard → character → director) | | `LoopAgent` | 하위 에이전트 반복 (escalate로 탈출) | style_critic (generator + critic), 씬 내 턴 루프 | | `BaseAgent` | LLM 없이 코드로 직접 처리 | input_guard(규칙 기반 1차), scene_context_loader, 상태 전이 검증 | | `CustomAgent` | BaseAgent 확장. 조건 분기, 동적 에이전트 선택 | 씬 타입별 파이프라인 선택 | ### 절대 하지 말 것 - `output_schema`와 `tools` 동시 사용 — **Gemini 3.0만 지원**, 다른 모델에서는 안 됨. 필요하면 sub-agent로 출력 포맷팅을 분리할 것 - `mode="ANY"` 사용 (무한 루프 발생) - instruction 안에 예시 텍스트로 `{한국어}` 사용 (state 변수로 파싱됨 → 에러) - Python 코드로 ADK 워크플로우를 대체 (keyword matching, hardcoded fallback 등) - `LlmAgent`의 `output_key`로 **구조화된 값을 기대** — LLM 텍스트만 저장됨. director의 수렴 판정처럼 구조화 값이 필요하면 `FunctionTool`이 `tool_context.state`에 직접 쓰거나 BaseAgent로 구현 - 검색/컨텍스트 주입 경로에서 `state_id` 필터를 생략 (스포일러 사고) - LLM 판단만으로 씬/챕터 전이 (반드시 `services/scene.py`의 코드 검증을 거칠 것) - 원작 원문을 프롬프트에 기억으로 재현 (반드시 repository 조회) ### Escalate scope 격리 (매우 중요) ADK `LoopAgent`는 sub_agent의 `escalate=True` 이벤트를 **상위로 yield**한다. 즉 중첩 LoopAgent가 있으면 바깥 루프까지 함께 탈출한다(전 프로젝트 실측 확인). 이로 인해: - **SequentialAgent는 escalate를 인식하지 않음**. escalate가 필요한 자리에 놓으려면 해당 범위만 `LoopAgent(max_iterations=1)`로 감쌀 것 - **내부 LoopAgent의 성공-escalate가 바깥을 오염시키면 안 되는 경우**엔 반드시 바깥을 SequentialAgent로 두고, escalate를 소화할 scope만 LoopAgent로 - 씬 턴 파이프라인 설계 시 이 규칙을 적용한 기준 구조: ``` scene_session (SequentialAgent) ├── scene_context_loader ← BaseAgent, YAML/그래프에서 씬 브리프 로드 ├── turn_loop (LoopAgent max=씬별 max_turns) ← director의 수렴-escalate가 여기서 그침 │ ├── input_guard │ ├── character │ └── director ← 수렴 판정 시 escalate └── scene_closer (LoopAgent max=1) ← 강제 수렴 내레이션 + 상태 전이 커밋 ``` ### 권위 소스 원칙 **"한 정보의 진실은 한 곳에만 있어야 한다."** - 서사 내용(인물/관계/사건/지식상태) 권위: `content/yaml/` (적재된 Neo4j는 그 사본). 코드에 서사 내용 하드코딩 금지 - 씬 진행 상태 권위: `session.state` (씬 내) → 씬 종료 시 `scene.py`가 DB에 커밋 (영속). 두 곳을 동시에 갱신하는 코드 금지 - 현재 유저의 서사 위치 권위: DB `play_session.current_state_id`. 검색 필터는 이 값으로만 - 문체 기준 권위: `content/style/`. 프롬프트마다 문체 지시를 즉흥 작성하지 않는다 - 수렴 판정 권위: director의 구조화 출력 + `scene.py`의 코드 검증. LLM 텍스트 파싱으로 판정 금지 --- ## LLM 모델 ```python # engine/model.py from google.adk.models.lite_llm import LiteLlm _MODEL_ID = os.environ.get("LLM_MODEL", "openai/gpt-5.4-nano") LLM_MODEL = LiteLlm(model=_MODEL_ID) ``` - 로컬 테스트: 저비용 모델로 파이프라인 검증 - 캐릭터/문체 품질이 중요한 character, style_critic은 상위 모델을 별도 env로 분리 가능하게 (`CHARACTER_MODEL`) - 모델 교체가 잦으므로 프롬프트에 특정 모델 의존 문법을 넣지 않는다 --- ## 테스트 & 검증 ```bash # 단위 테스트 python3 -m pytest tests/ -v # 콘텐츠 스키마 검증 python3 -m engine.schemas.validate content/ # 스포일러 필터 회귀 테스트 (state N에서 N+1 정보 누출 여부) python3 -m pytest tests/test_spoiler_scope.py -v # 평가 하네스 (LLM judge: 원작 충실도 / 문체 / 수렴 성공률) python3 -m eval.run --scene --n 10 # Docker 빌드 확인 docker compose up -d --build backend docker compose logs --tail=20 backend ``` - 프롬프트 변경은 평가 하네스 점수와 함께 PR에 첨부한다 (감이 아니라 숫자로) - 턴 로그는 삭제하지 않는다 (연구 데이터) --- ## 커밋 규칙 - **main에 직접 푸시 금지.** 작업은 무조건 feature 브랜치에서 하고 PR로 올린다 - 파일 수정 전 반드시 Read로 현재 내용 확인 - 구조 변경 시 "복사 → 전환 → 삭제" 순서 (기존 기능 유지) - Docker 빌드로 검증 후 커밋 - 비트카드 MD 원본과 `content/` 하위 콘텐츠 내용은 개발 커밋에서 수정하지 않는다 (콘텐츠 변경은 별도 PR + 대표님 승인. 변환 스크립트 재실행 산출물 갱신은 예외) --- ## 참고 링크 - ADK 공식문서: https://google.github.io/adk-docs/ - ADK GitHub: https://github.com/google/adk-python - Neo4j GraphRAG Python: https://github.com/neo4j/neo4j-graphrag-python - LiteLLM 문서: https://docs.litellm.ai/docs/providers