- 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>
227 lines
8.2 KiB
Python
227 lines
8.2 KiB
Python
"""Phase 16 — 3축 매칭 (구조 × 내용 × 핵심키워드) 테스트
|
||
5개 프레임 수동 태깅(v2 스키마) + 4개 MDX 유닛 매칭
|
||
|
||
매칭 공식:
|
||
score = w_kw × 핵심키워드_Jaccard + w_struct × 구조_일치 + w_content × 내용_Cross점수
|
||
|
||
MDX 측 (코드로 실시간 추출):
|
||
- 구조: _detect_mdx_layout (본문 블릿 수, 표 유무)
|
||
- 내용: MDX 제목 (대제목 / 중제목 / 소제목)
|
||
- 핵심키워드: Kiwi 형태소 중 corpus-rare만 (IDF 기반)
|
||
"""
|
||
import sys
|
||
import math
|
||
import collections
|
||
import json
|
||
import re
|
||
from pathlib import Path
|
||
|
||
import yaml
|
||
|
||
sys.path.insert(0, str(Path(__file__).parent))
|
||
|
||
from common import load_figma_texts
|
||
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")
|
||
PREVIEW_DIR = ROOT / "data" / "figma_previews"
|
||
|
||
|
||
def load_v2():
|
||
p = Path(__file__).parent / "metadata_db_v2_sample.yaml"
|
||
with open(p, encoding="utf-8") as f:
|
||
return yaml.safe_load(f)["frames"]
|
||
|
||
|
||
def extract_mdx_3axis(unit_id, units_dict, mdx_titles):
|
||
"""MDX에서 코드로 3축 추출"""
|
||
text = units_dict[unit_id]
|
||
# 구조
|
||
structure = _detect_mdx_layout(text)
|
||
# 내용 = 대/중/소 제목 합친 것
|
||
content = mdx_titles[unit_id]
|
||
# 핵심키워드: Kiwi 내용어 전체 (corpus-rare 필터는 후속에서 vocab 기반)
|
||
kiwi = _get_kiwi()
|
||
tokens = _extract_content_tokens(text, kiwi)
|
||
return structure, content, set(tokens)
|
||
|
||
|
||
def jaccard_weighted(set_a, set_b, vocab_idf):
|
||
"""IDF 가중 Jaccard (vocab_idf가 있으면 사용, 없으면 단순 Jaccard)"""
|
||
if not set_a or not set_b:
|
||
return 0.0
|
||
inter = set_a & set_b
|
||
union = set_a | set_b
|
||
if vocab_idf:
|
||
num = sum(vocab_idf.get(c, 0.5) for c in inter)
|
||
den = sum(vocab_idf.get(c, 0.5) for c in union)
|
||
return num / den if den > 0 else 0
|
||
return len(inter) / len(union)
|
||
|
||
|
||
def structural_match(mdx_structure, fig_structure):
|
||
"""구조 일치도: exact=1, 유사 layout=0.5, 불일치=0"""
|
||
if not mdx_structure or not fig_structure:
|
||
return 0.0
|
||
if mdx_structure == fig_structure:
|
||
return 1.0
|
||
# 유사 그룹 (예: 3col-parallel vs persona-3col는 둘 다 3열)
|
||
if "3col" in mdx_structure and "3col" in fig_structure:
|
||
return 0.5
|
||
if "2col" in mdx_structure and "2col" in fig_structure:
|
||
return 0.5
|
||
if "parallel" in mdx_structure and "parallel" in fig_structure:
|
||
return 0.3
|
||
return 0.0
|
||
|
||
|
||
def cross_content_score(mdx_content, fig_content):
|
||
"""내용 의미 유사도 — Cross-encoder"""
|
||
model = _get_cross_encoder()
|
||
raw = model.predict([[mdx_content, fig_content]], show_progress_bar=False)
|
||
# sigmoid
|
||
import math as _m
|
||
return 1 / (1 + _m.exp(-float(raw[0])))
|
||
|
||
|
||
def method_3axis(mdx_structure, mdx_content, mdx_keywords,
|
||
v2_frames, w_kw=0.5, w_struct=0.2, w_content=0.3, verbose=False):
|
||
"""v2 프레임 대상 3축 가중 매칭"""
|
||
# IDF: v2_frames 전체의 핵심키워드 corpus에서
|
||
df = collections.Counter()
|
||
N = len(v2_frames)
|
||
for fid, entry in v2_frames.items():
|
||
for c in entry.get("핵심키워드", []):
|
||
df[c] += 1
|
||
idf = {c: math.log(N / cnt) if cnt > 0 else 0 for c, cnt in df.items()}
|
||
|
||
# MDX 핵심키워드 = Kiwi 토큰 중 v2 vocab에 있는 것만 (canonicalization)
|
||
vocab = set()
|
||
for entry in v2_frames.values():
|
||
vocab.update(entry.get("핵심키워드", []))
|
||
vocab.update(entry.get("일반키워드", []))
|
||
mdx_kw_canonical = mdx_keywords & vocab
|
||
|
||
scores = []
|
||
breakdown = {}
|
||
for fid, entry in v2_frames.items():
|
||
fig_kw = set(entry.get("핵심키워드", []))
|
||
fig_struct = entry.get("구조", "")
|
||
fig_content = entry.get("내용", "")
|
||
|
||
kw_s = jaccard_weighted(mdx_kw_canonical, fig_kw, idf)
|
||
struct_s = structural_match(mdx_structure, fig_struct)
|
||
content_s = cross_content_score(mdx_content, fig_content) if fig_content else 0.0
|
||
|
||
final = w_kw * kw_s + w_struct * struct_s + w_content * content_s
|
||
scores.append((fid, final))
|
||
breakdown[fid] = {
|
||
"kw": kw_s,
|
||
"struct": struct_s,
|
||
"content": content_s,
|
||
"final": final,
|
||
"inter_kw": sorted(mdx_kw_canonical & fig_kw),
|
||
}
|
||
return sorted(scores, key=lambda x: -x[1]), breakdown
|
||
|
||
|
||
def main():
|
||
units = extract_units()
|
||
v2 = load_v2()
|
||
|
||
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()}
|
||
|
||
# 5 프레임에 대한 short ID 미리 확인
|
||
for fid in v2:
|
||
sid = frame_to_short.get(fid, "?")
|
||
print(f" v2 프레임 {fid} = short {sid} ({v2[fid]['구조']})")
|
||
print()
|
||
|
||
png_rel = "../../data/figma_previews/"
|
||
lines = []
|
||
lines.append("# Phase 16 — 3축 매칭 (구조×내용×핵심키워드) 테스트")
|
||
lines.append("")
|
||
lines.append("**5개 프레임 수동 v2 태깅** (13, 14, 15, 18, 29)")
|
||
lines.append("**4개 MDX 유닛** 매칭 테스트")
|
||
lines.append("")
|
||
lines.append(f"**매칭 공식**: `score = 0.5 × 핵심키워드_Jaccard + 0.2 × 구조_일치 + 0.3 × 내용_Cross`")
|
||
lines.append("")
|
||
|
||
# 결과 요약 전체
|
||
lines.append("## 결과 요약")
|
||
lines.append("")
|
||
lines.append("| MDX 유닛 | 정답 | Top-1 | 결과 |")
|
||
lines.append("|---------|------|-------|------|")
|
||
|
||
detail_sections = []
|
||
|
||
for uid, display, correct_sid, *_ in TARGET_UNITS:
|
||
mdx_struct, mdx_content, mdx_kw = extract_mdx_3axis(uid, units, mdx_titles)
|
||
ranked, breakdown = method_3axis(mdx_struct, mdx_content, mdx_kw, v2)
|
||
|
||
top_fid, top_score = ranked[0]
|
||
top_sid = frame_to_short.get(top_fid, "?")
|
||
mark = "✓" if top_sid == correct_sid else "✗"
|
||
|
||
lines.append(f"| {display} | **{correct_sid}** | **{top_sid}** ({top_score:.3f}) | {mark} |")
|
||
|
||
# 상세 섹션
|
||
d = []
|
||
d.append(f"### {display} — 정답 Frame **{correct_sid}**")
|
||
d.append("")
|
||
d.append(f"**MDX 추출 (코드):**")
|
||
d.append(f"- 구조: `{mdx_struct}`")
|
||
d.append(f"- 내용: `{mdx_content}`")
|
||
d.append(f"- 핵심키워드 (Kiwi 추출, {len(mdx_kw)}개 중 v2 vocab 교집합): "
|
||
f"`{sorted(mdx_kw & set().union(*[set(e.get('핵심키워드', []) + e.get('일반키워드', [])) for e in v2.values()]))[:15]}`")
|
||
d.append("")
|
||
d.append("**5개 후보 점수 분해:**")
|
||
d.append("")
|
||
d.append("| Frame | 핵심키워드 | 구조 | 내용 | 최종 | 공통키워드 |")
|
||
d.append("|-------|----------|------|------|------|-----------|")
|
||
for fid, _ in ranked:
|
||
sid = frame_to_short.get(fid, "?")
|
||
b = breakdown[fid]
|
||
mark_row = " ⭐" if sid == correct_sid else ""
|
||
common_kw = ", ".join(b["inter_kw"][:8])
|
||
d.append(f"| **{sid}**{mark_row} | {b['kw']:.3f} | {b['struct']:.2f} | "
|
||
f"{b['content']:.3f} | **{b['final']:.3f}** | {common_kw} |")
|
||
d.append("")
|
||
detail_sections.append("\n".join(d))
|
||
|
||
lines.append("")
|
||
lines.append("---")
|
||
lines.append("")
|
||
lines.append("## 유닛별 상세 (3축 점수 분해)")
|
||
lines.append("")
|
||
for s in detail_sections:
|
||
lines.append(s)
|
||
|
||
out_path = Path(__file__).parent / "MATRIX_PHASE16.md"
|
||
out_path.write_text("\n".join(lines), encoding="utf-8")
|
||
print(f"완료: {out_path}")
|
||
|
||
# 콘솔 요약
|
||
print("\n=== 3축 매칭 결과 ===")
|
||
for uid, display, correct_sid, *_ in TARGET_UNITS:
|
||
mdx_struct, mdx_content, mdx_kw = extract_mdx_3axis(uid, units, mdx_titles)
|
||
ranked, _ = method_3axis(mdx_struct, mdx_content, mdx_kw, v2)
|
||
top_sid = frame_to_short.get(ranked[0][0], "?")
|
||
mark = "✓" if top_sid == correct_sid else "✗"
|
||
print(f" {uid}(정답={correct_sid}): Top-1={top_sid} ({ranked[0][1]:.3f}) {mark}")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|