"""MDX ↔ Figma Frame 매칭 (엄밀한 헤딩 구조 + 팝업 포함 버전). mdx_normalizer 대신 raw MDX를 직접 파싱하여: - 중목차 = ## 헤딩 (오직 ## 만) - 소목차 = ### 헤딩 (오직 ### 만) - 팝업 =
......
(summary + body 분리 보존) 출력 레벨: L1 대목차 : MDX 전체 raw text (팝업 body 포함) L2 중목차 : 각 ## 섹션 본문 + 그 섹션 안 팝업 body 포함 L3 소목차 : 각 ### 섹션 본문 (해당 섹션에 속한 팝업 body 포함) L4 팝업 : 각
의 summary + body 단독 쿼리 매칭 엔진은 src/block_matcher_tfidf.py 그대로 재사용. """ from __future__ import annotations import json import re import sys from datetime import datetime from pathlib import Path sys.path.insert(0, str(Path(__file__).parent.parent)) from src.block_matcher_tfidf import TfidfBlockMatcher TOP_K = 3 PREVIEW_DIR = Path("data/figma_previews") INDEX_PATH = PREVIEW_DIR / "index.json" _INDEX: dict[str, dict] = json.loads(INDEX_PATH.read_text(encoding="utf-8")) FRAME_TO_NUM: dict[str, str] = {v["frame_id"]: k for k, v in _INDEX.items()} MDX_FILES = [ ("01", Path("samples/mdx/01. 건설산업 DX의 올바른 이해(0127).mdx")), ("02", Path("samples/mdx/02. DX의 시행 목표 및 기대효과.mdx")), ("03", Path("samples/mdx/03. DX 시행을 위한 필수 요건 및 혁신 방안.mdx")), ] # ═══════════════════════════════════════════════════════════ # MDX 파서 # ═══════════════════════════════════════════════════════════ def strip_tags(text: str) -> str: """HTML/JSX 태그 제거 + 마크다운 포맷 기호 살짝 정리.""" text = re.sub(r"<[^>]+>", " ", text) text = re.sub(r"\{/\*.*?\*/\}", " ", text, flags=re.DOTALL) text = re.sub(r"\{[^{}]*\}", " ", text) # JSX prop {…} text = text.replace("\\", " ") text = re.sub(r"\s+", " ", text).strip() return text def parse_details(text: str) -> list[dict]: """MDX 내 모든
블록을 추출. summary + body 반환. 각 엔트리: {"summary": str, "body": str, "raw_start": int, "raw_end": int} """ popups: list[dict] = [] for m in re.finditer(r"]*>([\s\S]*?)
", text, re.IGNORECASE): block = m.group(1) sm = re.search(r"]*>([\s\S]*?)", block, re.IGNORECASE) summary = strip_tags(sm.group(1)) if sm else "" body = block[sm.end():] if sm else block popups.append({ "summary": summary, "body": strip_tags(body), "raw_start": m.start(), "raw_end": m.end(), }) return popups def parse_mdx_structure(raw: str) -> dict: """Raw MDX → 헤딩 트리 + 팝업 목록. 반환: { "doc_title": str, # frontmatter title 또는 None "intro": str, # 첫 ## 이전 텍스트 "h2": [ { "title": str, "body": str, # ## 섹션 본문 (### 이전까지) "h3": [ {"title": str, "body": str, "popups": [...]}, ... ], "popups": [ {"summary", "body"} ], # 이 ## 섹션에 직접 속한 팝업 }, ... ], "all_popups": [ ... ], # 전체 팝업 } """ # frontmatter에서 title fm = re.match(r"---\s*\n([\s\S]*?)\n---\s*\n", raw) doc_title = None if fm: tm = re.search(r"^title:\s*(.+)$", fm.group(1), re.MULTILINE) if tm: doc_title = tm.group(1).strip().strip('"').strip("'") raw_body = raw[fm.end():] else: raw_body = raw # ## 헤딩 찾기 h2_iter = list(re.finditer(r"^##\s+(.+?)$", raw_body, re.MULTILINE)) intro = raw_body[: h2_iter[0].start()].strip() if h2_iter else raw_body.strip() h2_nodes: list[dict] = [] for i, m in enumerate(h2_iter): title = m.group(1).strip() start = m.end() end = h2_iter[i + 1].start() if i + 1 < len(h2_iter) else len(raw_body) section_raw = raw_body[start:end] # ### 헤딩 h3_iter = list(re.finditer(r"^###\s+(.+?)$", section_raw, re.MULTILINE)) body_before_h3 = section_raw[: h3_iter[0].start()] if h3_iter else section_raw h3_nodes: list[dict] = [] for j, m3 in enumerate(h3_iter): t3 = m3.group(1).strip() s3 = m3.end() e3 = h3_iter[j + 1].start() if j + 1 < len(h3_iter) else len(section_raw) sub_raw = section_raw[s3:e3] sub_popups = parse_details(sub_raw) # body = sub_raw + 팝업 본문 합친 문자열(쿼리용) — 팝업은 inline이므로 raw에 이미 포함되지만 태그 제거 후 텍스트로 강조 h3_nodes.append({ "title": t3, "body": strip_tags(sub_raw), "popups": sub_popups, }) # 이 ## 섹션 직속 팝업 (### 이전 부분에 있는 것) section_popups = parse_details(body_before_h3) h2_nodes.append({ "title": title, "body": strip_tags(body_before_h3), "h3": h3_nodes, "popups": section_popups, }) all_popups: list[dict] = [] all_popups.extend(parse_details(intro)) for h2 in h2_nodes: all_popups.extend(h2["popups"]) for h3 in h2["h3"]: all_popups.extend(h3["popups"]) return { "doc_title": doc_title, "intro": strip_tags(intro), "intro_popups": parse_details(intro), "h2": h2_nodes, "all_popups": all_popups, } # ═══════════════════════════════════════════════════════════ # 매칭 + 출력 # ═══════════════════════════════════════════════════════════ def num_of(frame_id: str) -> str: return FRAME_TO_NUM.get(frame_id, f"?({frame_id})") def frame_title(matcher: TfidfBlockMatcher, fid: str) -> str: for f in matcher.frames: if f["frame_id"] == fid: return (f.get("title_text") or "").replace("\n", " ")[:60] return "" def run_query(matcher: TfidfBlockMatcher, query_text: str) -> list[dict]: """쿼리 텍스트 하나를 받아서 matcher로 돌린다. block_matcher_tfidf.match는 (zone_title, sub_titles, d1_items) 인자를 받지만 내부에서는 단순히 문자열로 합쳐 전처리 → TF-IDF 유사도. 여기서는 전체 쿼리를 첫 인자(zone_title)로 넣어 동일한 전처리 경로를 탄다. """ return matcher.match(query_text, sub_titles=None, d1_items=None, top_k=len(matcher.frames)) def print_ranking(top: list[dict], matcher: TfidfBlockMatcher, indent: str = " "): if not top or top[0]["score"] <= 0: print(f"{indent}(매칭 없음, score=0)") return for rank, r in enumerate(top[:TOP_K], start=1): if r["score"] <= 0: break num = num_of(r["frame_id"]) print( f"{indent} {rank}. #{num} score={r['score']*100:5.1f}% " f"| frame {r['frame_id']} | {frame_title(matcher, r['frame_id'])}" ) def md_ranking_table(top: list[dict], matcher: TfidfBlockMatcher, run_dir_depth: int = 3) -> list[str]: rel = Path(*[".." for _ in range(run_dir_depth)]) / PREVIEW_DIR lines = [ "| rank | # | preview | score | frame_id | title_text |", "|---|---|---|---|---|---|", ] for rank, r in enumerate(top[:TOP_K], start=1): if r["score"] <= 0: break num = num_of(r["frame_id"]) preview = f"![]({(rel / (num + '.png')).as_posix()})" lines.append( f"| {rank} | **#{num}** | {preview} | **{r['score']*100:.1f}%** | " f"`{r['frame_id']}` | {frame_title(matcher, r['frame_id'])} |" ) if len(lines) == 2: lines.append("| — | — | — | 0% | — | (매칭 없음) |") return lines def evaluate_mdx(matcher: TfidfBlockMatcher, mdx_id: str, mdx_path: Path, md_lines: list[str]): raw = mdx_path.read_text(encoding="utf-8") parsed = parse_mdx_structure(raw) doc_title = parsed["doc_title"] or mdx_path.stem print("\n" + "=" * 100) print(f"MDX {mdx_id}: {doc_title} ({mdx_path.name})") print(f" 중목차(##) {len(parsed['h2'])}개 · " f"소목차(###) {sum(len(h2['h3']) for h2 in parsed['h2'])}개 · " f"팝업(
) {len(parsed['all_popups'])}개") print("=" * 100) md_lines.append(f"\n## MDX {mdx_id} — {doc_title}\n") md_lines.append(f"파일: `{mdx_path.as_posix()}`") md_lines.append( f"- 중목차(##) **{len(parsed['h2'])}개** · " f"소목차(###) **{sum(len(h2['h3']) for h2 in parsed['h2'])}개** · " f"팝업(
) **{len(parsed['all_popups'])}개**\n" ) # ─── L1 대목차: 전체 MDX ─── full_text = strip_tags(raw) l1_top = run_query(matcher, full_text) print(f"\n┌─ L1 대목차 [전체 MDX, 팝업 포함]") print_ranking(l1_top, matcher, indent="│ ") md_lines.append("### 🟦 L1 — 대목차 (전체 MDX, 팝업 포함)\n") md_lines.extend(md_ranking_table(l1_top, matcher)) md_lines.append("") # ─── L2 중목차: 각 ## ─── print(f"\n┌─ L2 중목차 [## 섹션별]") md_lines.append("\n### 🟩 L2 — 중목차 (각 ## 섹션, 팝업 body 포함)\n") for zi, h2 in enumerate(parsed["h2"], start=1): # 쿼리: ## title + body(### 이전) + 직속 popup + 각 ### body/popup parts = [h2["title"], h2["body"]] for p in h2["popups"]: parts.append(p["summary"]) parts.append(p["body"]) for h3 in h2["h3"]: parts.append(h3["title"]) parts.append(h3["body"]) for p in h3["popups"]: parts.append(p["summary"]) parts.append(p["body"]) query = " ".join(parts) top = run_query(matcher, query) pop_titles = [p["summary"] for p in h2["popups"]] + [ p["summary"] for h3 in h2["h3"] for p in h3["popups"] ] print(f"│\n│ [중 {zi}] ## {h2['title']}") print(f"│ 소목차: {[h3['title'] for h3 in h2['h3']] or '(없음)'}") print(f"│ 팝업: {pop_titles or '(없음)'}") print_ranking(top, matcher, indent="│ ") md_lines.append(f"\n#### 중 {zi}: `## {h2['title']}`\n") md_lines.append(f"- 소목차(###): {[h3['title'] for h3 in h2['h3']] or '(없음)'}") md_lines.append(f"- 팝업: {pop_titles or '(없음)'}\n") md_lines.extend(md_ranking_table(top, matcher)) md_lines.append("") # ─── L3 소목차: 각 ### ─── total_h3 = sum(len(h2["h3"]) for h2 in parsed["h2"]) print(f"\n┌─ L3 소목차 [### 섹션별, 총 {total_h3}개]") if total_h3 == 0: print("│ (이 MDX에는 ### 소목차 없음)") md_lines.append(f"\n### 🟨 L3 — 소목차 (각 ### 섹션)\n") if total_h3 == 0: md_lines.append("_(이 MDX에는 ### 소목차 없음)_\n") for h2 in parsed["h2"]: for h3 in h2["h3"]: parts = [h3["title"], h3["body"]] for p in h3["popups"]: parts.append(p["summary"]) parts.append(p["body"]) query = " ".join(parts) top = run_query(matcher, query) pop_titles = [p["summary"] for p in h3["popups"]] print(f"│\n│ [소] ### {h3['title']} (상위 중목차: {h2['title']})") print(f"│ 팝업: {pop_titles or '(없음)'}") print_ranking(top, matcher, indent="│ ") md_lines.append( f"\n#### 소: `### {h3['title']}` _(상위 중목차: `{h2['title']}`)_\n" ) md_lines.append(f"- 팝업: {pop_titles or '(없음)'}\n") md_lines.extend(md_ranking_table(top, matcher)) md_lines.append("") # ─── L4 팝업: 각
─── print(f"\n┌─ L4 팝업 [
단독 매칭, 총 {len(parsed['all_popups'])}개]") if not parsed["all_popups"]: print("│ (팝업 없음)") md_lines.append(f"\n### 🟥 L4 — 팝업 (
단독 매칭)\n") if not parsed["all_popups"]: md_lines.append("_(팝업 없음)_\n") for pi, pop in enumerate(parsed["all_popups"], start=1): query = f"{pop['summary']} {pop['body']}" top = run_query(matcher, query) preview = pop["body"][:80] + ("…" if len(pop["body"]) > 80 else "") print(f"│\n│ [팝 {pi}] summary={pop['summary']!r} ({len(pop['body'])}자)") print(f"│ preview: {preview}") print_ranking(top, matcher, indent="│ ") md_lines.append(f"\n#### 팝 {pi}: `
` — **{pop['summary']}**\n") md_lines.append(f"- 본문 길이: {len(pop['body'])}자 · preview: _{preview}_\n") md_lines.extend(md_ranking_table(top, matcher)) md_lines.append("") def build_frame_legend(matcher: TfidfBlockMatcher, md_lines: list[str]) -> None: md_lines.append("\n## 프레임 번호 전체 색인 (01 ~ 32)\n") md_lines.append("| # | preview | frame_id | title_text |") md_lines.append("|---|---|---|---|") rel = Path("..") / ".." / ".." / PREVIEW_DIR for num in sorted(_INDEX.keys()): entry = _INDEX[num] preview = f"![]({(rel / (num + '.png')).as_posix()})" md_lines.append( f"| **#{num}** | {preview} | `{entry['frame_id']}` | " f"{frame_title(matcher, entry['frame_id'])} |" ) md_lines.append("") def main() -> int: print("[init] TF-IDF 인덱스 로딩...") matcher = TfidfBlockMatcher() print(f"[init] 프레임 {len(matcher.frames)}개 인덱싱 완료") md_lines: list[str] = [ "# MDX ↔ Figma Frame 매칭 (엄밀한 구조 + 팝업 포함)", "", "raw MDX를 직접 파싱하여 **## 만 중목차**, **### 만 소목차**, `
` 을 팝업으로 분리.", "bullet 항목(`* **제목**`)은 헤딩이 아니므로 섹션 body에 포함되며 별도 레벨로 취급하지 않음.", "", "| 단계 | 입도 | 쿼리 구성 |", "|---|---|---|", "| 🟦 L1 대목차 | MDX 1개 | 전체 MDX raw text (팝업 body 포함) |", "| 🟩 L2 중목차 | 각 `##` | `## title + body + 하위 ### body + 팝업 body` |", "| 🟨 L3 소목차 | 각 `###` | `### title + body + 자기 팝업 body` |", "| 🟥 L4 팝업 | 각 `
` | `summary + body` |", "", "점수는 순수 TF-IDF cosine similarity × 100 (%). 판정 라벨 없음.", ] for mdx_id, p in MDX_FILES: if p.exists(): evaluate_mdx(matcher, mdx_id, p, md_lines) else: print(f"[skip] 없음: {p}") build_frame_legend(matcher, md_lines) ts = datetime.now().strftime("%Y%m%d_%H%M%S") out_dir = Path("data/runs") / f"{ts}_mdx_match_strict" out_dir.mkdir(parents=True, exist_ok=True) out = out_dir / "match_report.md" out.write_text("\n".join(md_lines), encoding="utf-8") print(f"\n[saved] {out}") return 0 if __name__ == "__main__": raise SystemExit(main())