전체 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>
392 lines
15 KiB
Python
392 lines
15 KiB
Python
"""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").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").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())
|