Spaces:
Sleeping
Sleeping
| """๋นํธ์นด๋ 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) | |
| 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() | |