전체 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>
239 lines
9.1 KiB
Python
239 lines
9.1 KiB
Python
"""MDX → Figma Frame 매칭 (TF-IDF) — 대목차 / 중목차 / 소목차 3단계 모두 출력.
|
||
|
||
프레임은 data/figma_previews/index.json 의 번호(01~32)로 표기한다.
|
||
"""
|
||
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
|
||
from src.mdx_normalizer import normalize_mdx_content
|
||
from src.section_parser import extract_major_sections
|
||
|
||
|
||
TOP_K = 3
|
||
THRESHOLD = 0.15 # pipeline_v2 direct-fit 커트오프 (표기에 사용 안 함)
|
||
|
||
PREVIEW_DIR = Path("data/figma_previews")
|
||
INDEX_PATH = PREVIEW_DIR / "index.json"
|
||
|
||
# index.json 로드: {"01": {"frame_id": "1171281172", ...}, ...}
|
||
_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()}
|
||
NUM_TO_FRAME: dict[str, str] = {k: v["frame_id"] 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")),
|
||
]
|
||
|
||
|
||
def num_of(frame_id: str) -> str:
|
||
return FRAME_TO_NUM.get(frame_id, f"?({frame_id})")
|
||
|
||
|
||
def extract_d1_items(content: str) -> list[str]:
|
||
return [
|
||
re.sub(r"\*+", "", d).strip()
|
||
for d in re.findall(r"^D1:\s*(.*)", content, re.MULTILINE)
|
||
]
|
||
|
||
|
||
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 print_ranking(label: str, 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) -> list[str]:
|
||
rel = Path("..") / ".." / ".." / PREVIEW_DIR # run 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],
|
||
) -> None:
|
||
content = mdx_path.read_text(encoding="utf-8")
|
||
norm = normalize_mdx_content(content)
|
||
flat_sections = norm.get("sections", [])
|
||
zones = extract_major_sections(flat_sections)
|
||
doc_title = norm.get("title") or mdx_path.stem
|
||
|
||
print("\n" + "=" * 100)
|
||
print(f"MDX {mdx_id}: {doc_title} ({mdx_path.name})")
|
||
print(f"flat sections: {len(flat_sections)} | zones(중목차): {len(zones)}")
|
||
print("=" * 100)
|
||
|
||
md_lines.append(f"\n## MDX {mdx_id} — {doc_title}\n")
|
||
md_lines.append(
|
||
f"파일: `{mdx_path.as_posix()}` · "
|
||
f"평면 section {len(flat_sections)}개 · zone(중목차) {len(zones)}개\n"
|
||
)
|
||
|
||
# ═══════════ L1: 대목차 (MDX 전체) ═══════════
|
||
l1_subs = [z["title"] for z in zones] + [
|
||
st for z in zones for st in z.get("sub_titles", [])
|
||
]
|
||
l1_top = matcher.match(doc_title, l1_subs, d1_items=None, top_k=len(matcher.frames))
|
||
|
||
print(f"\n┌─ L1 대목차 [전체 MDX] '{doc_title}'")
|
||
print(f"│ zones: {[z['title'] for z in zones]}")
|
||
print_ranking("L1", l1_top, matcher, indent="│ ")
|
||
|
||
md_lines.append("### 🟦 L1 — 대목차 (전체 MDX)\n")
|
||
md_lines.append(f"- 쿼리: `{doc_title}` + 모든 zone/sub title")
|
||
md_lines.append(f"- zone 목록: {[z['title'] for z in zones]}")
|
||
md_lines.append("")
|
||
md_lines.extend(md_ranking_table(l1_top, matcher))
|
||
md_lines.append("")
|
||
|
||
# ═══════════ L2: 중목차 (zone 단위) ═══════════
|
||
print(f"\n┌─ L2 중목차 [zone 단위]")
|
||
md_lines.append("### 🟩 L2 — 중목차 (zone 단위)\n")
|
||
|
||
for zi, zone in enumerate(zones, start=1):
|
||
z_title = zone["title"]
|
||
sub_titles = zone.get("sub_titles", [])
|
||
z_content = zone.get("content", "")
|
||
d1 = extract_d1_items(z_content)
|
||
top = matcher.match(z_title, sub_titles, d1, top_k=len(matcher.frames))
|
||
|
||
print(f"│\n│ [zone {zi}] {z_title}")
|
||
print(f"│ sub_titles: {sub_titles}")
|
||
print(f"│ d1_items: {len(d1)}개")
|
||
print_ranking("L2", top, matcher, indent="│ ")
|
||
|
||
md_lines.append(f"\n#### zone {zi}: **{z_title}**")
|
||
md_lines.append(f"- sub_titles: {sub_titles}")
|
||
md_lines.append(f"- d1_items: {len(d1)}개")
|
||
md_lines.append("")
|
||
md_lines.extend(md_ranking_table(top, matcher))
|
||
md_lines.append("")
|
||
|
||
# ═══════════ L3: 소목차 (평면 section 각각) ═══════════
|
||
# normalize의 sections 중 content가 있는 것만 = 실제 소목차
|
||
sub_sections = [s for s in flat_sections if s.get("content", "").strip()]
|
||
print(f"\n┌─ L3 소목차 [개별 sub-section, {len(sub_sections)}개]")
|
||
md_lines.append("### 🟨 L3 — 소목차 (개별 sub-section)\n")
|
||
|
||
for si, sec in enumerate(sub_sections, start=1):
|
||
s_title = sec.get("title", "")
|
||
s_content = sec.get("content", "")
|
||
d1 = extract_d1_items(s_content)
|
||
top = matcher.match(s_title, sub_titles=None, d1_items=d1, top_k=len(matcher.frames))
|
||
|
||
# 이 섹션이 어느 zone에 속하는지 찾기
|
||
parent_zone = "—"
|
||
for z in zones:
|
||
if s_title in z.get("sub_titles", []):
|
||
parent_zone = z["title"]
|
||
break
|
||
|
||
print(f"│\n│ [sub {si}] {s_title} (zone: {parent_zone})")
|
||
print(f"│ d1_items: {len(d1)}개")
|
||
print_ranking("L3", top, matcher, indent="│ ")
|
||
|
||
md_lines.append(f"\n#### sub {si}: **{s_title}** _(zone: {parent_zone})_")
|
||
md_lines.append(f"- d1_items: {len(d1)}개")
|
||
md_lines.append("")
|
||
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)}개 인덱싱 완료")
|
||
print(f"[init] direct-fit 임계값 = {THRESHOLD*100:.0f}%")
|
||
|
||
md_lines: list[str] = [
|
||
"# MDX ↔ Figma Frame 매칭 (TF-IDF 순수 점수) — L1/L2/L3 3단계",
|
||
"",
|
||
"프레임은 `data/figma_previews/{번호}.png` 의 번호로 표기. 하단에 번호-프레임 색인.",
|
||
"",
|
||
"| 단계 | 입도 | 쿼리 구성 |",
|
||
"|---|---|---|",
|
||
"| 🟦 L1 대목차 | MDX 전체 1개 | doc title + 모든 zone/sub title |",
|
||
"| 🟩 L2 중목차 | zone 단위 | zone title + sub_titles + d1_items |",
|
||
"| 🟨 L3 소목차 | 개별 sub-section 각각 | sub title + 자기 content의 d1_items |",
|
||
"",
|
||
f"- 인덱싱된 프레임: {len(matcher.frames)}개",
|
||
"- **각 표는 순수 TF-IDF cosine similarity × 100 을 %로 표시한 점수 랭킹.**",
|
||
"- 판정/분기(recipe/direct-fit) 라벨은 출력하지 않음. 점수만 그대로 본다.",
|
||
]
|
||
|
||
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)
|
||
|
||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||
out_dir = Path("data/runs") / f"{timestamp}_mdx_match"
|
||
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())
|