Junhoee's picture
Upload 162 files
1c9c13f verified
Raw
History Blame Contribute Delete
5.5 kB
"""๋น„ํŠธ์นด๋“œ 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 = "" # ํŒŒ์„œ๊ฐ€ ๊ตฌ์กฐํ™”ํ•˜์ง€ ๋ชปํ•œ ์ž”์—ฌ ๋ณธ๋ฌธ (๊ฒ€์ฆ ๋ฆฌํฌํŠธ ๋Œ€์ƒ)