"""비트카드 YAML 스키마 (내러티브 레이어). 권위는 비트카드 MD(대표님 소유). content/beatcards/*.yaml 은 scripts/beatcard_convert.py 산출물이며, 이 스키마가 그 구조를 잠근다. 축(axis) 표기: - 카드 태그 `#축/신뢰+` `#축/의심+` → 카드 진입 시 적용 (choices 없는 카드만) - 선택지 결과 블록 `〈신뢰+ · …〉` → 해당 선택 시 적용 """ from __future__ import annotations from typing import Literal, Optional from pydantic import BaseModel, ConfigDict, Field Axis = Literal["trust", "doubt", "neutral"] NARRATION_TYPES = ("prose", "dialogue_center", "stage_direction", "character_dialogue") # 발화 직전의 이 길이 이하 prose는 리드인 — 렌더가 스테이지 화면 상단에 얹는다 LEAD_IN_MAX_CHARS = 140 def is_lead_in(block_type: str, text: str, next_type: str) -> bool: """리드인 유도 규칙 (단일 권위 — 변환기·적재가 공유).""" return (block_type == "prose" and len(text) <= LEAD_IN_MAX_CHARS and next_type == "character_dialogue") class NarrationBlock(BaseModel): """고정 지문 블록. gate가 있으면 신뢰 레벨에 따라 노출이 갈린다.""" model_config = ConfigDict(extra="forbid") type: Literal["prose", "dialogue_center", "stage_direction", "character_dialogue"] text: str speaker: str = "" anchor: bool = False # True = 개입 포켓에서 LLM 변주 대상 앵커 대사 gate: Literal["all", "trust_high"] = "all" # 〈표면·전원〉 / 〈심층·신뢰高 해금〉 # 화면 배치 역할 (연출 구조화). 미지정이면 적재 시 유도 규칙(derive_roles) 적용: # lead_in = 발화 직전의 짧은 도입 prose (스테이지 화면에 얹힘) · aftermath = 예약 role: Optional[Literal["lead_in", "aftermath"]] = None def derive_roles(narration: list[NarrationBlock]) -> None: """role 미지정 블록에 유도 규칙 적용 (제자리 갱신 — 명시 role은 존중).""" for i, b in enumerate(narration[:-1]): if b.role is None and is_lead_in(b.type, b.text, narration[i + 1].type): b.role = "lead_in" class Choice(BaseModel): """선택지. axis/result/next는 결과 블록 매칭으로 채워지며 미확정이면 None.""" model_config = ConfigDict(extra="forbid") label: str modifiers: list[str] = Field(default_factory=list) # 〔능동발견〕 등 axis: Optional[Axis] = None result: str = "" # 선택 직후 고정 결과 산문 next: Optional[str] = None # 분기 카드 (없으면 card.next 따름) # 하류 분기 카드(도달점)의 선택을 선행 커밋. 도달점을 건너뛰지 않기 위한 표현: # 예) E06-09 동조 → E06-10은 정상 통과하되 E06-10의 분기는 E06-10C로 예약 preset_branch: Optional[str] = None class BeatCard(BaseModel): model_config = ConfigDict(extra="forbid") id: str title: str subtitle: str = "" tags: list[str] = Field(default_factory=list) status: str = "초안" canon: bool = False next: Optional[str] = None # 카드 ID 또는 에피소드 참조(E07 등) next_options: list[str] = Field(default_factory=list) # E06-10B/C 형 분기 후보 axis: Optional[Axis] = None # 카드 진입 시 축 이동 (#축/ 태그, 단일값일 때만) frailty: bool = False # #메커니즘/쇠약도 — 진입 시 쇠약도 +1 narration: list[NarrationBlock] = Field(default_factory=list) choices: list[Choice] = Field(default_factory=list) r_card_refs: list[str] = Field(default_factory=list) note: str = "" direction: str = "" image: str = "" @property def is_pocket(self) -> bool: """개입 포켓 여부 — 선택지 또는 앵커 대사(자유채팅 변주 대상)가 있으면 포켓.""" return bool(self.choices) or any(b.anchor for b in self.narration) @property def can_chat(self) -> bool: """자유채팅 허용 — 카르밀라가 앵커 화자인 카드만 (MVP: 카르밀라 중심 큐레이션). 아버지/마드모아젤 등 다른 화자의 포켓은 선택지만 제공한다 (캐릭터 에이전트가 카르밀라 전용이므로, 타 화자 채팅은 2단계).""" return any(b.anchor and "카르밀라" in (b.speaker or "") for b in self.narration) @property def episode_id(self) -> str: return self.id.split("-")[0] class EpisodeMeta(BaseModel): model_config = ConfigDict(extra="forbid") id: str title: str = "" source: str = "" class EpisodeFile(BaseModel): """content/beatcards/E##.yaml 한 파일 = 에피소드 하나.""" model_config = ConfigDict(extra="forbid") episode: EpisodeMeta cards: list[BeatCard] class RCardStage(BaseModel): model_config = ConfigDict(extra="forbid") stage: int title: str = "" anchor: str = "" # 대표 대사 ❝…❞ prose: str = "" variants: list[str] = Field(default_factory=list) class RCard(BaseModel): """R카드 — 회차 없는 재사용 카드 (회피사다리/약점반응).""" model_config = ConfigDict(extra="forbid") id: str title: str = "" tags: list[str] = Field(default_factory=list) rules: list[str] = Field(default_factory=list) # 🔒 절대 규칙 stages: list[RCardStage] = Field(default_factory=list) raw: str = "" # 파서가 구조화하지 못한 잔여 본문 (검증 리포트 대상)