Files
C.E.L_Slide_test2/scripts/match_mdx_strict.py
kyeongmin 85c680f02a docs + V4 catalog + samples + Phase Q legacy 보존
전체 26 files (20 추가 + 6 수정), 10507 insertions.

Phase Z 문서 :
- docs/architecture/PHASE-Z-CHANGE-LOG.md (신설) — axis-by-axis 의사결정 history
  (newest-on-top). Step 7-A 부터 6 entry 박힘 + 2026-05-08 / 2026-05-08 #2
  (compat 매트릭스 폐기 / 6-B 폐기 / F14 표현 정정 / label gate policy 분리).
- docs/architecture/PHASE-Z-PIPELINE-OVERVIEW.md (수정) — Step 5/6/9 Gap note
  append (구조 무변, append-only). 6-B 폐기 사실 + Refinement F.
- docs/architecture/PHASE-Z-PIPELINE-STATUS-BOARD.md (수정) — snapshot date
  2026-05-08 갱신. §3 핵심 missing item 5 (Step 5/6/9 boundary axis breakdown
  + 폐기 기록). §6 한 줄 갱신 — 다음 axis 후보 A~F.

Project root docs :
- PLAN.md / PROGRESS.md / README.md (수정) — 토큰 체계 / 폴더 구조 / 설계 문서 /
  역할 분리 반영.
- IMPROVEMENT-REDESIGN.md (신설) — Phase Z 설계 핵심 문서.
- PROCESS_OVERVIEW.html (신설) — 파이프라인 개요 시각.
- docs/tasks/* (신설) — Phase Z task 문서.

V4 catalog (Phase Z runtime 필수 의존성) :
- tests/matching/v4_full32_result.yaml (신설, 4888 줄) — V4 매칭 결과 32 frame
  × 10 MDX section. lookup_v4_match() / lookup_v4_candidates() 가 본 파일 read.
  Phase Z runtime 이 *없으면 즉시 abort* — clone 후 즉시 동작 가능 보장.

Samples :
- samples/mdx_batch/04.mdx (신설) — MDX04 기본 sample.
- samples/mdx/04. DX 지연 요인.mdx (신설) — MDX04 원본.

Phase Q legacy 보존 (별 axis "Phase Q audit & salvage" 영역) :
- src/block_matcher_tfidf.py / catalog_blocks.py / frame_extractor.py /
  pipeline_v2.py — Phase Q (옛 파이프라인) src 신규 untracked 파일들.
  Phase Z runtime 와 의존성 0. Phase Q audit axis 에서 검토 예정.
- scripts/eval_block_matcher.py / fetch_all_frame_screenshots.py /
  match_17_units_my_matcher.py / match_mdx_strict.py / match_mdx_to_frames_tfidf.py /
  ocr_augment_texts.py / run_pipeline_v2.py / previews/ — Phase Q 작업 시
  사용한 옛 script. 같이 보존.
- run_mdx03_pipeline.py (수정) — Phase Q 진입점 (no flag) + Phase Z 진입점
  (--phase-z2 flag) 동시 wrapper. Phase Z 만 사용 시 `python -m
  src.phase_z2_pipeline samples/mdx_batch/03.mdx <run_id>` 직접 호출.

비-scope :
- tests/matching/ (v4_full32_result.yaml 외 ~63MB) — V4 진화 history /
  reports / DECK / ATTACH. Phase Q audit axis 에서 검토.
- tests/pipeline/ (~15MB) — pipeline data. Phase Q audit 영역.
- templates/catalog/blocks.yaml — 옛 block catalog. Phase Q audit.
- templates/phase_z2/frames/ — 옛 frame partial 위치. Phase Q audit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 09:47:58 +09:00

392 lines
15 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""MDX ↔ Figma Frame 매칭 (엄밀한 헤딩 구조 + 팝업 포함 버전).
mdx_normalizer 대신 raw MDX를 직접 파싱하여:
- 중목차 = ## 헤딩 (오직 ## 만)
- 소목차 = ### 헤딩 (오직 ### 만)
- 팝업 = <details><summary>...</summary>...</details> (summary + body 분리 보존)
출력 레벨:
L1 대목차 : MDX 전체 raw text (팝업 body 포함)
L2 중목차 : 각 ## 섹션 본문 + 그 섹션 안 팝업 body 포함
L3 소목차 : 각 ### 섹션 본문 (해당 섹션에 속한 팝업 body 포함)
L4 팝업 : 각 <details> 의 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 내 모든 <details> 블록을 추출. summary + body 반환.
각 엔트리: {"summary": str, "body": str, "raw_start": int, "raw_end": int}
"""
popups: list[dict] = []
for m in re.finditer(r"<details\b[^>]*>([\s\S]*?)</details>", text, re.IGNORECASE):
block = m.group(1)
sm = re.search(r"<summary\b[^>]*>([\s\S]*?)</summary>", 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"팝업(<details>) {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"팝업(<details>) **{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 팝업: 각 <details> ───
print(f"\n┌─ L4 팝업 [<details> 단독 매칭, 총 {len(parsed['all_popups'])}개]")
if not parsed["all_popups"]:
print("│ (팝업 없음)")
md_lines.append(f"\n### 🟥 L4 — 팝업 (<details> 단독 매칭)\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}: `<details>` — **{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를 직접 파싱하여 **## 만 중목차**, **### 만 소목차**, `<details>` 을 팝업으로 분리.",
"bullet 항목(`* **제목**`)은 헤딩이 아니므로 섹션 body에 포함되며 별도 레벨로 취급하지 않음.",
"",
"| 단계 | 입도 | 쿼리 구성 |",
"|---|---|---|",
"| 🟦 L1 대목차 | MDX 1개 | 전체 MDX raw text (팝업 body 포함) |",
"| 🟩 L2 중목차 | 각 `##` | `## title + body + 하위 ### body + 팝업 body` |",
"| 🟨 L3 소목차 | 각 `###` | `### title + body + 자기 팝업 body` |",
"| 🟥 L4 팝업 | 각 `<details>` | `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())