Files
C.E.L_Slide_test2/tests/matching/common.py
T
KyeongminandClaude Opus 4.8 b836e79ee1 wip: phase_z2 evidence 파이프라인 + matching 실험(phase2~26) + 프론트 trace 패널 진행분 스냅샷
- src: phase_z2 composition/mapper/pipeline/placement_planner/retry, ai_fallback(prompts/schema/validate), mdx_text_atoms 신규
- Front: PipelineTracePanel 신규, FramePanel/SlideCanvas/Home/designAgentApi 등 갱신 + 테스트 4종 추가
- templates/phase_z2: catalog(component_expansion_registry, node_slot_mapping 신규), frames, families, slide_base 갱신
- tests/matching: phase2~26 매칭 실험 스크립트·리포트·온톨로지 전체 (미커밋 진행분)
- tests: b4_v4 evidence, task5~28.5 시리즈, regression(imp95 baseline) 등 신규 테스트 대량 추가
- docs/reference: MDX 구조 인벤토리, MDX→Frame 구조 계약 문서
- scripts: mdx 계약/parity/coverage/viewport 체크, gitea comment, run sync 유틸
- .gitignore: tmp*.json, chromedriver, .orchestrator, *.pkl, Front_test* 등 임시/스냅샷 제외

미완성 작업의 보존용 스냅샷 커밋 (2026-07-02)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 17:03:42 +09:00

191 lines
6.5 KiB
Python

"""MDX ↔ Figma frame matching — 공통 유틸리티"""
import os
import re
import yaml
from pathlib import Path
ROOT = Path(r"d:\ad-hoc\kei\design_agent")
BLOCKS_DIR = ROOT / "figma_to_html_agent" / "blocks"
MDX_DIR = ROOT / "samples" / "mdx_batch"
GT_PATH = ROOT / "tests" / "matching" / "ground_truth.yaml"
def load_ground_truth():
with open(GT_PATH, encoding="utf-8") as f:
return yaml.safe_load(f)["sections"]
def _strip_figma_meta(text):
"""texts.md의 메타 헤더/구조 라벨 제거 + 타이틀 3회 반복(가중치).
- '# Frame 1171281XXX — 텍스트 목록' 파일 최상위 제목 제거
- '> ...' blockquote 메타 제거
- '## 타이틀', '## 서브헤더', '## 열1', '### 세로 라벨' 구조 라벨 제거
- '## 타이틀' 아래 bullet 또는 plain 라인을 3회 반복
"""
lines = text.split("\n")
cleaned = []
title_lines = []
in_title_section = False
for ln in lines:
if re.match(r"^#\s+Frame\s+\d+", ln):
continue
if ln.startswith("> "):
continue
# ## 타이틀 시작
if re.match(r"^##\s+타이틀", ln):
in_title_section = True
continue
# 다른 ## 나 ### 헤더 → 타이틀 구역 종료 (헤더 자체는 제거)
if re.match(r"^#{2,}\s", ln):
in_title_section = False
continue
# 타이틀 구역 안의 비어있지 않은 라인은 수집 (bullet이든 plain이든)
if in_title_section:
s = ln.strip()
if s:
# 앞의 '- ' 제거해서 깔끔하게
clean_s = re.sub(r"^-\s*", "", s)
if clean_s:
title_lines.append(clean_s)
cleaned.append(ln)
continue
cleaned.append(ln)
out = "\n".join(cleaned).strip()
if title_lines:
boost = "\n".join(title_lines)
out = boost + "\n" + boost + "\n" + out
return out
def load_figma_texts(clean_meta=True):
"""32개 프레임의 texts.md 읽기. {frame_id: text}
clean_meta=True: 메타 헤더/라벨 제거 + 타이틀 가중치 적용 (권장)
"""
out = {}
for d in sorted(os.listdir(BLOCKS_DIR)):
p = BLOCKS_DIR / d / "texts.md"
if p.is_file():
with open(p, encoding="utf-8") as f:
text = f.read()
if clean_meta:
text = _strip_figma_meta(text)
out[d] = text
return out
def load_mdx_sections():
"""MDX 01~03을 ## 중목차 단위로 분리.
{section_id: section_text}. section_id는 GT의 id와 매칭."""
sections = {}
for fname in ["01.mdx", "02.mdx", "03.mdx"]:
mdx_num = fname.replace(".mdx", "")
with open(MDX_DIR / fname, encoding="utf-8") as f:
content = f.read()
# frontmatter 제거
content = re.sub(r"^---.*?---", "", content, flags=re.DOTALL).strip()
# import 문 제거
content = re.sub(r"^import .*$", "", content, flags=re.MULTILINE)
# ## 기준 분리
parts = re.split(r"(?=^## )", content, flags=re.MULTILINE)
intro_collected = False
sec_counter = 0
for part in parts:
part = part.strip()
if not part:
continue
if part.startswith("## "):
sec_counter += 1
key = f"MDX{mdx_num}-{sec_counter}"
else:
key = f"MDX{mdx_num}-intro"
intro_collected = True
sections[key] = part
return sections
def tokenize_simple(text):
"""단순 공백/특수문자 기준 토큰화."""
# HTML/MDX 태그 제거
text = re.sub(r"<[^>]+>", " ", text)
text = re.sub(r"\{[^}]+\}", " ", text)
text = re.sub(r"[|*#>\-\[\](){}:;,.!?/\\=\"'`~]", " ", text)
# 2글자 이상 토큰만
words = [w for w in text.split() if len(w) >= 2]
return words
def clean_text(text):
"""기본 정리 (HTML/MDX 제거)."""
text = re.sub(r"<[^>]+>", " ", text)
text = re.sub(r"\{[^}]+\}", " ", text)
text = re.sub(r"\s+", " ", text).strip()
return text
def char_ngrams(text, n):
"""겹치는 n글자 집합. 공백 제거 후."""
text = re.sub(r"\s+", "", clean_text(text))
return set(text[i:i + n] for i in range(max(0, len(text) - n + 1)))
def evaluate_ranking(gt_entry, ranked_frames):
"""한 섹션의 평가.
ranked_frames: [(frame_id, score), ...] top부터 내림차순.
returns: dict with hit@1, hit@3, mrr
"""
primary = gt_entry["primary"]
secondary = gt_entry.get("secondary", []) or []
if primary is None:
# null 정답: top-1 점수가 낮으면 OK (즉 "없음"을 잘 맞춤)
# 여기선 hit@1 = 1 if ranked_frames[0][1] < 0.2 else 0 같은 규칙
# 복잡하니 일단 null은 "모든 방법이 약한 점수 내야 성공"으로 단순 처리
# → hit 체크에서 제외, 별도 분석용
return {"hit@1": None, "hit@3": None, "mrr": None, "gt": None}
gt_set = {str(primary)} | {str(s) for s in secondary}
rank_ids = [str(fid) for fid, _ in ranked_frames]
hit1 = 1 if rank_ids and rank_ids[0] == str(primary) else 0
hit3 = 1 if any(r in gt_set for r in rank_ids[:3]) else 0
# MRR: primary의 역순위 (secondary는 MRR에서 제외 — 엄격)
mrr = 0.0
for i, r in enumerate(rank_ids, 1):
if r == str(primary):
mrr = 1.0 / i
break
return {"hit@1": hit1, "hit@3": hit3, "mrr": mrr, "gt": str(primary)}
def run_method(method_name, method_fn, mdx_sections, figma_texts, gt_list):
"""방법을 실행하고 결과 dict 반환.
method_fn(mdx_text, figma_texts_dict) -> [(frame_id, score), ...] top-k desc
"""
gt_by_id = {g["id"]: g for g in gt_list}
results = {}
for sec_id, sec_text in mdx_sections.items():
if sec_id not in gt_by_id:
continue
ranked = method_fn(sec_text, figma_texts)
results[sec_id] = {
"ranked": ranked[:5],
"eval": evaluate_ranking(gt_by_id[sec_id], ranked),
}
return {"method": method_name, "results": results}
def aggregate_metrics(method_result):
"""hit@1, hit@3, mrr 평균 (null GT 제외)"""
vals = {"hit@1": [], "hit@3": [], "mrr": []}
for sec_id, r in method_result["results"].items():
e = r["eval"]
if e["gt"] is None:
continue
for k in vals:
vals[k].append(e[k])
return {
k: (sum(v) / len(v) if v else 0.0) for k, v in vals.items()
}