"""비트카드 MD → content/ YAML 변환기 (개발팀 소유). 사용법: .venv/bin/python scripts/beatcard_convert.py <비트카드_폴더> -o content/beatcards - 입력: 카르밀라/0701_카르밀라/비트카드/ (권위, 대표님 소유 — 내용 변형 금지) - 출력: content/beatcards/E##.yaml + R-*.yaml - 구조 변환만 한다. 매칭이 불확실한 선택지 결과는 리포트로 표시하고 axis=None으로 남긴다. """ from __future__ import annotations import argparse import re import sys from pathlib import Path import yaml # engine.schemas로 즉시 검증하기 위해 저장소 루트를 경로에 추가 sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) from engine.schemas.beatcard import EpisodeFile, RCard # noqa: E402 AXIS_MAP = {"신뢰+": "trust", "의심+": "doubt", "중립": "neutral", "흘림": "neutral", "수동": "neutral"} GATE_MARKS = {"표면": "all", "심층": "trust_high"} RE_CARD_HEADER = re.compile(r"^##\s+(?:★\s*)?([A-Z]\d{2}-\d{2}[A-Z]?)\s*[··]\s*(?:★\s*)?([^(\n]+?)(?:\s*\(([^)]*)\))?\s*$", re.M) RE_TAG = re.compile(r"`(#[^`]+)`") RE_NEXT = re.compile(r"·\s*→\s*([\w가-힣/\-]+)") RE_CHOICE_HEADER = re.compile(r"^\*\*▷\s*(.+?)\*\*", re.M) RE_CHOICE_LABEL = re.compile(r"\[([^\]]+)\]") RE_CHOICE_MOD = re.compile(r"〔([^〕]+)〕") RE_CENTER = re.compile(r"❝\s*(.+?)\s*❞", re.S) RE_CHAR_DIALOGUE = re.compile(r"^>\s*([가-힣A-Za-z·\s]{1,20}?):\s*[「\"](.+?)[」\"]\s*$") RE_OLD_RESULT = re.compile(r"^-\s*\*([^:*]+):\*\s*(.+)$") RE_NEW_RESULT = re.compile(r"^〈([^·〉]+)\s*·\s*([^〉]*)〉\s*(.*)$") RE_RESULT_NEXT = re.compile(r"→\s*\*{0,2}([A-Z]\d{2}-\d{2}[A-Z]?)\*{0,2}") RE_BRANCH_MAP = re.compile(r"([가-힣]+)\s*→\s*\*\*([A-Z]\d{2}-\d{2}[A-Z]?)\*\*") RE_RCARD_REF = re.compile(r"R-[가-힣\w]+") RE_SECTION_STOP = re.compile(r"^(?:▸\s*노트:|🎬|🖼)") # 저작 메타 라인 — 지문으로 새면 안 되는 분기 매핑 메모 (예: "숨김 → **E06-10C** (…)"). # 볼드 카드ID 참조가 본문 대사/지문에 등장할 일은 없다 RE_META_LINE = re.compile(r"\*\*E\d{2}-") RE_STAGE = re.compile(r"^###\s*(\d)단계\s*[—–-]\s*(.+)$", re.M) def _bigrams(s: str) -> set[str]: s = re.sub(r"[^\w가-힣]", "", s) return {s[i:i + 2] for i in range(len(s) - 1)} | set(s) def _match_score(label: str, hint: str, text: str) -> float: """선택지 라벨 ↔ 결과 블록 유사도 (라벨힌트 가중 + 본문 앞부분).""" lb = _bigrams(label) return 3 * len(lb & _bigrams(hint)) + len(lb & _bigrams(text[:60])) class EpisodeParser: def parse(self, text: str, source: str) -> tuple[dict, list[str]]: warnings: list[str] = [] m = re.search(r"^#\s+(E\d{2})\s+비트\s*카드\s*[—–-]\s*(.+?)\s*(?:\(v.*)?$", text, re.M) ep_id, ep_title = (m.group(1), m.group(2).strip()) if m else ("UNKNOWN", "") cards = [] headers = list(RE_CARD_HEADER.finditer(text)) for i, h in enumerate(headers): body = text[h.end(): headers[i + 1].start() if i + 1 < len(headers) else len(text)] card, ws = self._parse_card(h.group(1), h.group(2).strip(), (h.group(3) or "").strip(), body) cards.append(card) warnings += [f"{card['id']}: {w}" for w in ws] self._resolve_branches(cards, warnings) self._demote_reach_skips(cards, warnings) return {"episode": {"id": ep_id, "title": ep_title, "source": source}, "cards": cards}, warnings # ── 카드 한 장 ────────────────────────────────────────── def _parse_card(self, cid: str, title: str, subtitle: str, body: str) -> tuple[dict, list[str]]: ws: list[str] = [] lines = body.split("\n") # 태그 라인 (첫 '>' 라인) tag_line = next((l for l in lines if l.strip().startswith(">") and "`#" in l), "") tags = RE_TAG.findall(tag_line) nxt, next_options = None, [] nm = RE_NEXT.search(tag_line) if nm: raw = nm.group(1) if "/" in raw: # E06-10B/C → [E06-10B, E06-10C] head, *rest = raw.split("/") prefix = head[: head.rfind(head.lstrip("A-Z0-9-")) ] if False else head base = re.match(r"([A-Z]\d{2}-\d{2})", head) next_options = [head] + [(base.group(1) + r) if base and len(r) <= 2 else r for r in rest] else: nxt = raw # 축/쇠약 태그 axis = None axis_tags = [t for t in tags if t.startswith("#축/")] if axis_tags: v = axis_tags[0][len("#축/"):] axis = AXIS_MAP.get(v) # "신뢰+·의심+" 같은 복합값 → None (선택지가 결정) frailty = any(t.startswith("#메커니즘/쇠약도") for t in tags) canon = any(t.startswith("#캐논/") for t in tags) status = next((t.split("/", 1)[1] for t in tags if t.startswith("#상태/")), "초안") # 본문 절단 (노트/연출/이미지 분리) note = self._grab(body, r"▸\s*노트:\s*(.+?)(?=\n🎬|\n🖼|\n---|\Z)") direction = self._grab(body, r"🎬\s*\*\*연출 포인트:\*\*\s*(.+?)(?=\n🖼|\n---|\Z)") image = self._grab(body, r"🖼\s*\*\*장면 이미지:\*\*\s*(.+?)(?=\n---|\Z)") # 선택지 라벨 choices: list[dict] = [] ch = RE_CHOICE_HEADER.search(body) if ch: seg = ch.group(1) mods = RE_CHOICE_MOD.findall(seg) for lab in RE_CHOICE_LABEL.findall(seg): choices.append({"label": lab.strip(), "modifiers": mods, "axis": None, "result": "", "next": None}) # 내레이션 + 결과 블록 narration, results = self._parse_blocks(lines, tag_line, ws) # 결과 블록 ↔ 선택지 매칭 if choices and results: self._match_results(choices, results, ws) elif results and not choices: ws.append(f"결과 블록 {len(results)}개가 선택지 없이 존재") # 분기 매핑 라인 (숨김 → **E06-10C**)은 전체 카드 파싱 후 후처리 (타깃 카드 본문 필요) branch_hints = RE_BRANCH_MAP.findall(body) r_refs = sorted(set(RE_RCARD_REF.findall(body))) card = { "id": cid, "title": title, "subtitle": subtitle, "tags": tags, "status": status, "canon": canon, "next": nxt, "next_options": next_options, "axis": axis, "frailty": frailty, "narration": narration, "choices": choices, "r_card_refs": r_refs, "note": note, "direction": direction, "image": image, } if branch_hints: card["_branch_hints"] = branch_hints # 후처리용 임시 키 (저장 전 제거) return card, ws def _demote_reach_skips(self, cards: list[dict], warnings: list[str]): """canon 도달점 우회 방지 — 선택지 next가 하류 분기카드의 옵션이면 preset_branch로 강등하고 정상 경로(card.next)를 따르게 한다.""" by_id = {c["id"]: c for c in cards} for card in cards: for ch in card["choices"]: target = ch.get("next") if not target: continue cur, hops = card, 0 while cur and cur.get("next") in by_id and hops < 6: cur = by_id[cur["next"]] hops += 1 if target in cur.get("next_options", []) and cur["id"] != target: ch["next"] = None ch["preset_branch"] = target warnings.append( f"{card['id']}: '{ch['label'][:14]}…' next={target}는 " f"도달점 {cur['id']} 우회 → preset_branch로 강등") break def _resolve_branches(self, cards: list[dict], warnings: list[str]): """분기 매핑 후처리 — 타깃 카드의 첫 지문과 선택지 라벨을 대조해 1:1 배정.""" by_id = {c["id"]: c for c in cards} for card in cards: hints = card.pop("_branch_hints", None) if not hints: continue used = set() for hint, target in hints: tgt = by_id.get(target) tgt_text = tgt["narration"][0]["text"] if tgt and tgt["narration"] else "" cands = [c for i, c in enumerate(card["choices"]) if i not in used] if not cands: warnings.append(f"{card['id']}: 분기 '{hint}→{target}' 배정할 선택지 없음") continue best = max(cands, key=lambda c: _match_score(c["label"], hint, "") + _match_score(c["label"], "", tgt_text)) score = (_match_score(best["label"], hint, "") + _match_score(best["label"], "", tgt_text)) if score <= 0: warnings.append(f"{card['id']}: 분기 '{hint}→{target}' 매칭 실패 — 수동 확인 필요") continue best["next"] = target used.add(card["choices"].index(best)) # ── 본문 블록 파싱 ────────────────────────────────────── def _parse_blocks(self, lines: list[str], tag_line: str, ws: list[str]): narration, results = [], [] buf: list[str] = [] gate = "all" in_results = False # ▷ 헤더 이후는 결과 영역 cur_result = None def flush_prose(): t = "\n".join(buf).strip() buf.clear() if t: narration.append({"type": "prose", "text": t, "gate": gate}) def close_result(): nonlocal cur_result if cur_result: # 연속 줄에 섞여 든 라우팅 메모(→ E##-…)도 절단 m = RE_RESULT_NEXT.search(cur_result["text"]) if m: cur_result["next"] = cur_result["next"] or m.group(1) cur_result["text"] = cur_result["text"][:m.start()] cur_result["text"] = cur_result["text"].strip() results.append(cur_result) cur_result = None for raw in lines: line = raw.rstrip() s = line.strip() if not s or s == tag_line.strip() or s.startswith("---"): continue if RE_SECTION_STOP.match(s): break if RE_META_LINE.search(s): # 분기 매핑 메모 — 지문 누수 차단 continue if RE_CHOICE_HEADER.match(s): flush_prose() close_result() in_results = True continue nr = RE_NEW_RESULT.match(s) if nr: mark, hint, rest = nr.group(1).strip(), nr.group(2).strip(), nr.group(3) if mark in GATE_MARKS: # 〈표면〉〈심층〉 = 지문 게이트 flush_prose() gate = GATE_MARKS[mark] if rest.strip(): buf.append(rest) continue flush_prose() close_result() nm = RE_RESULT_NEXT.search(rest) # 화살표부터는 라우팅 메모("→ E06-10(두 점 발견) 이후 …") — 본문에서 절단 cur_result = {"axis": AXIS_MAP.get(mark), "hint": hint, "text": (rest[:nm.start()] if nm else rest).strip(), "next": nm.group(1) if nm else None} continue orr = RE_OLD_RESULT.match(s) if orr and in_results: flush_prose() close_result() cur_result = {"axis": None, "hint": orr.group(1).strip(), "text": orr.group(2).strip(), "next": None} continue if cur_result is not None: # 결과 블록 연속 줄 cur_result["text"] += "\n" + s continue cm = RE_CENTER.search(s) if cm and s.startswith(">"): flush_prose() narration.append({"type": "dialogue_center", "text": cm.group(1).strip(), "gate": gate}) gate = "all" if gate == "trust_high" else gate # 심층 게이트는 대표대사까지 continue dm = RE_CHAR_DIALOGUE.match(s) if dm: flush_prose() narration.append({"type": "character_dialogue", "speaker": dm.group(1).strip(), "text": dm.group(2).strip(), "anchor": True, "gate": gate}) continue if re.match(r"^\*\(.+\)\*$", s): flush_prose() narration.append({"type": "stage_direction", "text": s.strip("*()").strip(), "gate": gate}) continue if s.startswith(">"): s = s.lstrip("> ").strip() buf.append(s) flush_prose() close_result() # 화면 배치 역할 유도 — 규칙 권위는 engine.schemas.beatcard.is_lead_in (적재 시에도 동일 적용) from engine.schemas.beatcard import is_lead_in for i, b in enumerate(narration[:-1]): if "role" not in b and is_lead_in(b["type"], b["text"], narration[i + 1]["type"]): b["role"] = "lead_in" # 스키마 기본값과 같은 키 제거 없이 그대로 두되, speaker/anchor 없는 블록은 기본값 생략 narration = [ {k: v for k, v in b.items() if not (k == "speaker" and not v) and not (k == "anchor" and not v) and not (k == "gate" and v == "all")} for b in narration ] return narration, results def _match_results(self, choices: list[dict], results: list[dict], ws: list[str]): """결과 블록을 선택지에 유사도 순으로 배정 (탐욕, 1:1).""" pairs = sorted( ((r_i, c_i, _match_score(c["label"], r["hint"], r["text"])) for r_i, r in enumerate(results) for c_i, c in enumerate(choices)), key=lambda t: -t[2], ) used_r, used_c = set(), set() for r_i, c_i, score in pairs: if r_i in used_r or c_i in used_c: continue r, c = results[r_i], choices[c_i] if score <= 0: ws.append(f"결과 '〈{r['hint']}〉' ↔ 선택지 매칭 실패 (score 0)") used_r.add(r_i) continue c["axis"], c["result"] = r["axis"], r["text"] if r["next"]: c["next"] = r["next"] if score < 3: ws.append(f"매칭 약함(score {score}): '{c['label']}' ← 〈{r['hint']}〉") used_r.add(r_i) used_c.add(c_i) @staticmethod def _grab(body: str, pattern: str) -> str: m = re.search(pattern, body, re.S) return re.sub(r"\s+", " ", m.group(1)).strip() if m else "" class RCardParser: def parse(self, text: str) -> dict: m = re.search(r"^#\s+(R-[가-힣\w]+)", text, re.M) rid = m.group(1) if m else "R-UNKNOWN" tm = re.search(r"^##\s+" + re.escape(rid) + r"\s*[··]\s*(.+)$", text, re.M) title = tm.group(1).strip() if tm else "" tag_line = next((l for l in text.split("\n") if l.strip().startswith(">") and "`#" in l), "") tags = RE_TAG.findall(tag_line) rules = [] rm = re.search(r"###\s*🔒[^\n]*\n(.*?)(?=\n###)", text, re.S) if rm: rules = [re.sub(r"\*\*", "", l.strip("- ").strip()) for l in rm.group(1).split("\n") if l.strip().startswith("-")] stages = [] stage_iter = list(RE_STAGE.finditer(text)) for i, sm in enumerate(stage_iter): seg = text[sm.end(): stage_iter[i + 1].start() if i + 1 < len(stage_iter) else len(text)] anchor_m = RE_CENTER.search(seg) variants = re.findall(r"-\s*(?:변형|물리 증거[^:]*):\s*(.+)", seg) prose = "\n".join( l.strip() for l in seg.split("\n") if l.strip() and not l.strip().startswith(("-", ">", "▸", "#")) ).strip() stages.append({"stage": int(sm.group(1)), "title": sm.group(2).strip(), "anchor": anchor_m.group(1).strip() if anchor_m else "", "prose": prose, "variants": variants}) return {"id": rid, "title": title, "tags": tags, "rules": rules, "stages": stages, "raw": "" if stages else text} def save(data: dict, path: Path): path.parent.mkdir(parents=True, exist_ok=True) path.write_text(yaml.dump(data, allow_unicode=True, sort_keys=False, width=100), encoding="utf-8") def main(): ap = argparse.ArgumentParser() ap.add_argument("input", help="비트카드 폴더 (E01~ 하위 폴더 + R-*.md)") ap.add_argument("-o", "--output", default="content/beatcards") ap.add_argument("--episodes", default="E01,E02,E03,E04,E05,E06") args = ap.parse_args() src, out = Path(args.input), Path(args.output) episodes = args.episodes.split(",") total_cards, all_warnings = 0, [] ep_parser = EpisodeParser() for ep in episodes: md = src / ep / f"{ep}_비트카드.md" if not md.exists(): print(f"⚠ 원본 없음: {md}") continue data, ws = ep_parser.parse(md.read_text(encoding="utf-8"), str(md)) EpisodeFile.model_validate(data) # 저장 전 스키마 검증 save(data, out / f"{ep}.yaml") total_cards += len(data["cards"]) all_warnings += ws print(f"✓ {ep}: 카드 {len(data['cards'])}개 → {out / (ep + '.yaml')}") r_parser = RCardParser() for rmd in sorted(src.glob("R-*.md")): data = r_parser.parse(rmd.read_text(encoding="utf-8")) RCard.model_validate(data) save(data, out / f"{data['id']}.yaml") print(f"✓ {data['id']}: 단계 {len(data['stages'])}개") print(f"\n총 {total_cards}카드 변환 완료") if all_warnings: print(f"\n점검 항목 {len(all_warnings)}건:") for w in all_warnings: print(f" ⚠ {w}") if __name__ == "__main__": main()