- 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>
329 lines
13 KiB
Python
329 lines
13 KiB
Python
"""Phase 17 — 32개 프레임 analysis.md 전체 로드 + 3축 매칭 (runtime df 분류)
|
||
Phase 16은 5개 v2 샘플 yaml로 검증했는데, 이제 실전 32개 analysis.md로 재검증.
|
||
|
||
analysis.md = 정적 (구조/내용/후보키워드만)
|
||
runtime에 32개 로드 → df 계산 → 핵심/중간/일반 자동 분류 → 매칭
|
||
"""
|
||
import sys
|
||
import re
|
||
import math
|
||
import collections
|
||
import json
|
||
from pathlib import Path
|
||
|
||
sys.path.insert(0, str(Path(__file__).parent))
|
||
|
||
from extract_units import extract_units
|
||
from methods import _get_kiwi, _extract_content_tokens, _detect_mdx_layout, _get_cross_encoder
|
||
from phase10 import extract_titles_only_mdx, TARGET_UNITS
|
||
|
||
ROOT = Path(r"d:\ad-hoc\kei\design_agent")
|
||
BLOCKS_DIR = ROOT / "figma_to_html_agent" / "blocks"
|
||
PREVIEW_DIR = ROOT / "data" / "figma_previews"
|
||
|
||
|
||
def parse_analysis_md(path):
|
||
"""analysis.md 파싱 → {layout, detail, content, keywords, ...}"""
|
||
text = path.read_text(encoding="utf-8")
|
||
result = {"keywords": [], "layout": "", "detail": "", "content": ""}
|
||
|
||
# Sections by ## headers
|
||
sections = re.split(r"\n## ", text)
|
||
for sec in sections[1:]:
|
||
lines = sec.split("\n", 1)
|
||
heading = lines[0].strip()
|
||
body = lines[1].strip() if len(lines) > 1 else ""
|
||
|
||
if heading == "구조":
|
||
for ln in body.split("\n"):
|
||
m = re.match(r"- \*\*layout\*\*:\s*(.+)", ln)
|
||
if m:
|
||
result["layout"] = m.group(1).strip()
|
||
m = re.match(r"- \*\*detail\*\*:\s*(.+)", ln)
|
||
if m:
|
||
result["detail"] = m.group(1).strip()
|
||
elif heading == "내용":
|
||
# 첫 문장 블록
|
||
result["content"] = body.split("\n\n")[0].strip()
|
||
elif heading == "후보 키워드":
|
||
# 쉼표로 분리
|
||
raw = body.split("\n\n")[0]
|
||
kws = [k.strip() for k in raw.split(",") if k.strip()]
|
||
result["keywords"] = kws
|
||
|
||
return result
|
||
|
||
|
||
def load_32_frames():
|
||
"""32개 프레임의 analysis.md를 로드"""
|
||
frames = {}
|
||
for d in sorted(BLOCKS_DIR.iterdir()):
|
||
if not d.is_dir():
|
||
continue
|
||
fid = d.name
|
||
if not fid.startswith("1171"):
|
||
continue
|
||
a = d / "analysis.md"
|
||
if a.exists():
|
||
frames[fid] = parse_analysis_md(a)
|
||
return frames
|
||
|
||
|
||
def compute_df_classes(frames):
|
||
"""전체 corpus에서 df 계산 → 후보 키워드별 핵심/중간/일반 분류
|
||
비율 기준: df/N ≤ 0.1 = 핵심, ≥ 0.3 = 일반, 그 사이 = 중간"""
|
||
df = collections.Counter()
|
||
N = len(frames)
|
||
for fid, v in frames.items():
|
||
for kw in set(v["keywords"]):
|
||
df[kw] += 1
|
||
tier = {}
|
||
for kw, cnt in df.items():
|
||
ratio = cnt / N
|
||
if ratio <= 0.10:
|
||
tier[kw] = "core"
|
||
elif ratio >= 0.30:
|
||
tier[kw] = "general"
|
||
else:
|
||
tier[kw] = "mid"
|
||
idf = {kw: math.log(N / cnt) if cnt > 0 else 0 for kw, cnt in df.items()}
|
||
return df, tier, idf, N
|
||
|
||
|
||
def extract_mdx_keywords(mdx_text, vocabulary):
|
||
"""MDX에서 Kiwi로 추출 후 vocabulary 교집합 (canonicalization)"""
|
||
kiwi = _get_kiwi()
|
||
tokens = _extract_content_tokens(mdx_text, kiwi)
|
||
return set(tokens) & vocabulary
|
||
|
||
|
||
def structural_match(mdx_layout, fig_layout):
|
||
if not mdx_layout or not fig_layout:
|
||
return 0.0
|
||
if mdx_layout == fig_layout:
|
||
return 1.0
|
||
if "3col" in mdx_layout and "3col" in fig_layout:
|
||
return 0.5
|
||
if "persona-3col" in fig_layout and "3col" in mdx_layout:
|
||
return 0.5
|
||
if "2col" in mdx_layout and "2col" in fig_layout:
|
||
return 0.5
|
||
if "table" in mdx_layout and "table" in fig_layout:
|
||
return 0.5
|
||
return 0.0
|
||
|
||
|
||
def match_3axis(mdx_layout, mdx_content, mdx_kws, frames, idf, tier,
|
||
w_kw=0.5, w_struct=0.2, w_content=0.3):
|
||
"""3축 매칭 — 핵심/중간/일반 가중 Jaccard + 구조 + 내용(Cross-encoder)"""
|
||
model = _get_cross_encoder()
|
||
# 내용 쌍 일괄 예측
|
||
pairs = [[mdx_content, frames[fid]["content"]] for fid in frames]
|
||
fids = list(frames.keys())
|
||
content_raw = model.predict(pairs, show_progress_bar=False)
|
||
# sigmoid로 [0,1] 정규화
|
||
content_scores = {fids[i]: 1 / (1 + math.exp(-float(content_raw[i])))
|
||
for i in range(len(fids))}
|
||
|
||
scores = []
|
||
for fid, v in frames.items():
|
||
fig_kws = set(v["keywords"])
|
||
fig_layout = v["layout"]
|
||
|
||
# 핵심키워드 Jaccard with tier weights
|
||
# core: idf full, mid: idf*0.5, general: idf*0.1
|
||
inter = mdx_kws & fig_kws
|
||
union = mdx_kws | fig_kws
|
||
tier_weight = {"core": 1.0, "mid": 0.5, "general": 0.1}
|
||
num = sum(idf.get(c, 0.5) * tier_weight.get(tier.get(c, "mid"), 0.5) for c in inter)
|
||
den = sum(idf.get(c, 0.5) * tier_weight.get(tier.get(c, "mid"), 0.5) for c in union)
|
||
kw_s = num / den if den > 0 else 0
|
||
|
||
struct_s = structural_match(mdx_layout, fig_layout)
|
||
content_s = content_scores[fid]
|
||
|
||
final = w_kw * kw_s + w_struct * struct_s + w_content * content_s
|
||
scores.append((fid, final, {
|
||
"kw": kw_s, "struct": struct_s, "content": content_s,
|
||
"final": final, "inter": sorted(inter),
|
||
}))
|
||
scores.sort(key=lambda x: -x[1])
|
||
return scores
|
||
|
||
|
||
def main():
|
||
units = extract_units()
|
||
mdx_titles = {}
|
||
for uid, _, _, fname, mid, sub in TARGET_UNITS:
|
||
mdx_titles[uid] = extract_titles_only_mdx(fname, mid, sub)
|
||
|
||
with open(PREVIEW_DIR / "index.json", encoding="utf-8") as f:
|
||
idx_data = json.load(f)
|
||
frame_to_short = {info["frame_id"]: sid for sid, info in idx_data.items()}
|
||
|
||
# 32 frames 로드
|
||
frames = load_32_frames()
|
||
print(f"로드된 프레임: {len(frames)}개")
|
||
|
||
# df 분류
|
||
df, tier, idf, N = compute_df_classes(frames)
|
||
print(f"corpus: N={N}")
|
||
core_count = sum(1 for t in tier.values() if t == "core")
|
||
mid_count = sum(1 for t in tier.values() if t == "mid")
|
||
gen_count = sum(1 for t in tier.values() if t == "general")
|
||
print(f"키워드 분류: 핵심 {core_count}개, 중간 {mid_count}개, 일반 {gen_count}개 (총 {len(tier)}개)")
|
||
|
||
# vocabulary
|
||
vocab = set()
|
||
for v in frames.values():
|
||
vocab.update(v["keywords"])
|
||
print(f"vocabulary 크기: {len(vocab)}")
|
||
|
||
# 일반(general) 키워드 상위 10개 (어떤 게 일반어로 분류됐나)
|
||
general_kws = sorted([kw for kw, t in tier.items() if t == "general"],
|
||
key=lambda k: -df[k])
|
||
print(f"일반 키워드 (df 많음 상위): {general_kws[:10]}")
|
||
print()
|
||
|
||
# 4 MDX 유닛 매칭
|
||
print("=" * 70)
|
||
print("4개 MDX 유닛 × 32개 프레임 3축 매칭")
|
||
print("=" * 70)
|
||
hits = 0
|
||
reports = []
|
||
for uid, display, correct_sid, *_ in TARGET_UNITS:
|
||
text = units[uid]
|
||
mdx_layout = _detect_mdx_layout(text)
|
||
mdx_content = mdx_titles[uid]
|
||
mdx_kws = extract_mdx_keywords(text, vocab)
|
||
ranked = match_3axis(mdx_layout, mdx_content, mdx_kws, frames, idf, tier)
|
||
top3 = ranked[:3]
|
||
top_fid, top_score, top_bd = top3[0]
|
||
top_sid = frame_to_short.get(top_fid, "?")
|
||
mark = "✓" if top_sid == correct_sid else "✗"
|
||
if top_sid == correct_sid:
|
||
hits += 1
|
||
print(f"{uid} (정답={correct_sid})")
|
||
print(f" MDX 구조={mdx_layout}, 키워드 {len(mdx_kws)}개 ({sorted(mdx_kws)[:8]}...)")
|
||
print(f" Top1: Frame {top_sid} ({top_score:.3f}) {mark}")
|
||
print(f" kw={top_bd['kw']:.3f}, struct={top_bd['struct']:.2f}, content={top_bd['content']:.3f}")
|
||
reports.append((uid, display, correct_sid, top3, mdx_layout, mdx_content, mdx_kws))
|
||
|
||
print(f"\n=== 총 {hits}/4 ===")
|
||
|
||
# MD 리포트
|
||
png_rel = "../../data/figma_previews/"
|
||
|
||
def frame_cell(sid, score):
|
||
info = idx_data.get(sid, {})
|
||
png = info.get("png", "")
|
||
title = info.get("title_text", "").strip().replace("\n", " ") or ""
|
||
if len(title) > 15:
|
||
title = title[:15] + "…"
|
||
return f"<br>**{sid}** ({score:.3f})<br>{title}"
|
||
|
||
lines = []
|
||
lines.append("# Phase 17 — MDX ↔ Figma 매칭 체계")
|
||
lines.append("")
|
||
|
||
# ═══ 1. 수행 프로세스 (요약) ═══
|
||
lines.append("## 1. 수행 프로세스 (요약)")
|
||
lines.append("")
|
||
# 헤더 한 줄: Step N + 제목 결합. 아래 행: 내용 / 예시
|
||
lines.append("| Step 1. Figma 정리 | Step 2. 단어 가중치 정리 | Step 3. MDX 키워드 추출 | Step 4. 유사도 계산 | Step 5. 최종 선정 |")
|
||
lines.append("|-------------------|------------------------|-----------------------|---------------------|-------------------|")
|
||
# 내용 행
|
||
lines.append(
|
||
"| 각 Figma 프레임마다 analysis.md 작성 (내용 요약 1문장 + 후보 키워드 15~25개)"
|
||
" | 32개 프레임에서 각 키워드 등장 프레임 수(df) 세어 3단계 가중치 부여<br>핵심(1~3개)×1.0, 중간(4~9개)×0.5, 일반(10개↑)×0.1"
|
||
" | MDX 본문을 Kiwi 형태소 분석기로 추출 → Figma 키워드 DB에 있는 단어만 남김"
|
||
" | 공식: `점수 = 0.5 × 키워드 겹침 + 0.3 × 내용 유사도`<br>① 키워드: IDF 가중 Jaccard<br>② 내용: ko-reranker(Cross-encoder) 로컬 모델"
|
||
" | 32개 프레임 전부 점수 계산 → Top-1 선정"
|
||
" |"
|
||
)
|
||
# 예시 행
|
||
lines.append(
|
||
"| Frame 13 (필수조건)<br>"
|
||
f"<br>"
|
||
"내용: \"DX 시행 3대 필수조건 — 기술/사람/자연\"<br>키워드: 필수조건, 기술, 디지털기술, 사람, 역량, 자연, 여건, 기반, 창의성, 3요소…"
|
||
" | '필수조건' 1/32 → 핵심 ×1.0<br>'여건' 1/32 → 핵심 ×1.0<br>'디지털' 5/32 → 중간 ×0.5"
|
||
" | MDX03-1 원문: \"DX 시행을 위한 필수 요건 — 기술·사람·자연\"<br>Kiwi → 127개 토큰<br>Figma DB 교집합 → 22개: DX, 필수, 요건, 기술, 디지털, 사람, 역량, 자연, 여건…"
|
||
" | MDX03-1 vs Frame 13<br>키워드 점수: 0.289<br>내용 점수: 0.731<br>최종: 0.5×0.289 + 0.3×0.731 = **0.364**"
|
||
" | MDX03-1 결과<br>1위 Frame 13 → 0.364<br>2위 Frame 15 → 0.269<br>3위 Frame 18 → 0.193"
|
||
" |"
|
||
)
|
||
lines.append("")
|
||
|
||
lines.append("---")
|
||
lines.append("")
|
||
lines.append("## 2. 테스트 결과")
|
||
lines.append("")
|
||
|
||
lines.append(f"**결과: {hits}/4 정답**")
|
||
lines.append("")
|
||
lines.append("| 콘텐츠 | 1순위 | 2순위 | 3순위 |")
|
||
lines.append("|--------|-------|-------|-------|")
|
||
for uid, display, correct_sid, top3_full, mdx_layout, mdx_content, mdx_kws in reports:
|
||
row = [f"**{display}**<br>정답 Frame **{correct_sid}**"]
|
||
for rank_idx in range(3):
|
||
fid, score, bd = top3_full[rank_idx]
|
||
sid = frame_to_short.get(fid, "?")
|
||
info = idx_data.get(sid, {})
|
||
png = info.get("png", "")
|
||
title = info.get("title_text", "").strip().replace("\n", " ") or ""
|
||
if len(title) > 15:
|
||
title = title[:15] + "…"
|
||
inner = f"<br>**{sid}** ({score:.3f})<br>{title}"
|
||
if sid == correct_sid:
|
||
cell = f"<div style='background:#fff3cd;border:3px solid #dc2626;padding:8px;border-radius:6px'>🎯 <b>정답</b><br>{inner}</div>"
|
||
else:
|
||
cell = inner
|
||
row.append(cell)
|
||
lines.append("| " + " | ".join(row) + " |")
|
||
lines.append("")
|
||
|
||
# ═══ 3. 테스트 결과 세부 ═══
|
||
lines.append("---")
|
||
lines.append("")
|
||
lines.append("## 3. 테스트 결과 세부")
|
||
lines.append("")
|
||
for uid, display, correct_sid, top3_full, mdx_layout, mdx_content, mdx_kws in reports:
|
||
text = units[uid]
|
||
ranked = match_3axis(mdx_layout, mdx_content, mdx_kws, frames, idf, tier)
|
||
top3 = ranked[:3]
|
||
lines.append(f"### {display} — 정답 Frame **{correct_sid}**")
|
||
lines.append("")
|
||
lines.append("**MDX 추출 사항**")
|
||
lines.append("")
|
||
lines.append(f"- **내용**: {mdx_content}")
|
||
lines.append(f"- **키워드** ({len(mdx_kws)}개): {', '.join(sorted(mdx_kws))}")
|
||
lines.append("")
|
||
# 순위별 매칭 내용 / 키워드 / 점수
|
||
for rank, (fid, score, bd) in enumerate(top3, 1):
|
||
sid = frame_to_short.get(fid, "?")
|
||
info = idx_data.get(sid, {})
|
||
png = info.get("png", "")
|
||
fig_content = frames[fid].get("content", "")
|
||
common_kws = ", ".join(bd["inter"]) if bd["inter"] else "(없음)"
|
||
is_correct = sid == correct_sid
|
||
|
||
if is_correct:
|
||
lines.append(f"**<span style='background:#fff3cd;border:2px solid #dc2626;padding:2px 8px;border-radius:3px'>🎯 {rank}위 Frame {sid} (정답)</span>**")
|
||
else:
|
||
lines.append(f"**{rank}위 Frame {sid}**")
|
||
lines.append("")
|
||
lines.append(f"")
|
||
lines.append("")
|
||
lines.append(f"- **매칭 내용**: {fig_content}")
|
||
lines.append(f"- **매칭 키워드**: {common_kws}")
|
||
lines.append(f"- **매칭 점수**: **{score:.3f}**")
|
||
lines.append("")
|
||
|
||
out_path = Path(__file__).parent / "MATRIX_PHASE17.md"
|
||
out_path.write_text("\n".join(lines), encoding="utf-8")
|
||
print(f"\n완료: {out_path}")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|