Files
C.E.L_Slide_test2/tests/matching/pipeline_06_2_mdx_matching.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

626 lines
26 KiB
Python

"""Generate MDX_MATCHING_REPORT.
The report compares MDX section keywords against frame keywords and shows the
top matching frames with Korean labels that are readable outside the codebase.
"""
from __future__ import annotations
import hashlib
from collections import defaultdict
from datetime import datetime
from pathlib import Path
import markdown
import yaml
HERE = Path(__file__).parent
INPUT_AUTO = HERE / "auto_anchor_candidates.yaml"
INPUT_NORMALIZED = HERE / "normalized_text_tokens.yaml"
OUT_YAML = HERE / "mdx_matching_result.yaml"
OUT_MD = HERE / "MDX_MATCHING_REPORT.md"
OUT_HTML = HERE / "MDX_MATCHING_REPORT.html"
# Current review weights. These are intentionally shown in the report.
WEIGHT_STANDALONE = 0.414 # Logistic Regression 학습 (TARGET 4, LOOCV 4/4)
WEIGHT_GROUP = 0.320
WEIGHT_RELATED = 0.265
# Review targets with known expected frames (ANSWER_MAP 잠금: 수정 금지).
ANSWER_MAP = {
"01-2": 18,
"02-2.2": 14,
"03-1": 13,
"03-2": 29,
}
# 홀드아웃 섹션 (블라인드 검증, 기대 프레임 미지정)
# 04-* 는 MDX 04 (DX 지연 요인) 신규 일반화 테스트 — 기대 프레임 없음
HOLDOUT_SECTIONS = ['01-1', '02-1', '02-2.1', '04-1', '04-2.1', '04-2.2']
# 기준 잠금 대상 파일 (홀드아웃 실행 시 sha256 기록)
LOCK_SNAPSHOT_FILES = [
'keyword_normalizer.py',
'synonyms.yaml',
'pipeline_01_extract_nodes.py',
'pipeline_04_normalize.py',
'pipeline_06_2_mdx_matching.py',
'pipeline_07_auto_anchor_candidates.py',
]
def sha256_file(path: Path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()
def build_lock_snapshot() -> dict:
"""홀드아웃 실행 시점의 기준 잠금 스냅샷."""
return {
'timestamp': datetime.now().isoformat(timespec='seconds'),
'files': {
name: sha256_file(HERE / name) if (HERE / name).exists() else None
for name in LOCK_SNAPSHOT_FILES
},
'config': {
'weights': {
'W1_standalone': WEIGHT_STANDALONE,
'W2_group': WEIGHT_GROUP,
'W3_related': WEIGHT_RELATED,
},
'answer_map': dict(ANSWER_MAP),
'holdout_sections': list(HOLDOUT_SECTIONS),
},
'principle': [
'검증 대상 고정 — ANSWER_MAP 4개 유지',
'기준 잠금 — 전처리/anchor/가중치 수정 금지',
'블라인드 실행 — 홀드아웃 기대 프레임 미지정',
'사후 평가 — 실행 후 사람이 결과 해석',
'수정 규칙 제한 — 섹션 맞추기 보정 금지, 공통 규칙만',
],
}
def is_standalone_keyword(frame_appearances: int, is_important: bool) -> bool:
return frame_appearances == 1 or (is_important and frame_appearances <= 2)
def write_html(md_text: str) -> None:
html_body = markdown.markdown(md_text, extensions=["tables"])
html = f"""<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="utf-8">
<title>키워드 기반 MDX 매칭</title>
<style>
body {{ font-family: -apple-system, "Segoe UI", Pretendard, sans-serif; max-width: 1400px; margin: 2em auto; padding: 0 1.5em 4em; line-height: 1.65; color: #222; background: #f8fafc; }}
h1 {{ border-bottom: 3px solid #2563eb; padding-bottom: 0.25em; }}
h2 {{ margin-top: 2.6em; background: #e0e7ff; padding: 0.6em 0.9em; border-left: 4px solid #0a6; border-radius: 4px; }}
h3 {{ margin-top: 1.5em; color: #1a365d; font-size: 1.05em; }}
table {{ border-collapse: collapse; margin: 0.35em 0 1em 0; font-size: 0.88em; background: #fff; box-shadow: 0 2px 6px rgba(0,0,0,0.05); }}
th, td {{ border: 1px solid #e2e8f0; padding: 8px; text-align: left; vertical-align: top; }}
th {{ background: #1e293b; color: #fff; font-weight: 700; text-align: center; }}
code {{ background: #f4f4f4; padding: 1px 6px; border-radius: 3px; font-size: 0.9em; color: #111; }}
details {{ margin: 0.7em 0; background: #fafafa; padding: 0.7em 0.9em; border-radius: 4px; border: 1px solid #ddd; }}
summary {{ cursor: pointer; color: #555; font-size: 0.94em; font-weight: 600; }}
hr {{ border: 0; border-top: 2px dashed #ccc; margin: 2em 0; }}
strong {{ color: #0a6; }}
/* 매트릭스 셀 이미지 */
.matrix-cell img {{ max-width: 240px; height: auto; display: block; margin: 0 auto 6px; border: 1px solid #cbd5e1; border-radius: 4px; }}
.matrix-cell {{ min-width: 260px; vertical-align: top; }}
.answer-badge {{ background: #fff3cd; border: 3px solid #dc2626; padding: 8px; border-radius: 6px; }}
.holdout-top {{ background: #eef2ff; border: 2px solid #4338ca; padding: 8px; border-radius: 6px; }}
.frame-label {{ display: block; margin-top: 4px; font-size: 0.9em; }}
.frame-label strong {{ color: #2563eb; font-size: 1.05em; }}
.frame-desc {{ display: block; color: #64748b; font-size: 0.82em; margin-top: 4px; }}
.score {{ color: #059669; font-weight: 600; }}
</style>
</head>
<body>
{html_body}
</body>
</html>"""
OUT_HTML.write_text(html, encoding="utf-8")
def build_frame_layers(auto: dict) -> tuple[dict, dict]:
frame_groups = defaultdict(list)
frame_keyword_info = defaultdict(dict)
for set_id, info in auto["source_text_sets"].items():
frame_id = info["frame_id"]
frame_groups[frame_id].append(
{
"source_text_raw": info["source_text_raw"],
"keywords": info["term_values"],
}
)
for term in info["terms"]:
keyword = term["token"]
if keyword not in frame_keyword_info[frame_id]:
frame_keyword_info[frame_id][keyword] = {
"frame_appearances": term["frame_df"],
"mdx_appearances": term["mdx_df"],
"is_important": term["is_special"],
}
frame_layers = {}
for frame_id, keyword_map in frame_keyword_info.items():
standalone = sorted(
keyword
for keyword, info in keyword_map.items()
if is_standalone_keyword(info["frame_appearances"], info["is_important"])
)
related = sorted(
keyword
for keyword, info in keyword_map.items()
if not is_standalone_keyword(info["frame_appearances"], info["is_important"])
)
frame_layers[frame_id] = {
"standalone_keywords": standalone,
"related_keywords": related,
"keyword_groups": frame_groups[frame_id],
}
frame_numbers = {
frame_id: auto["frame_stats"][frame_id]["frame_number"]
for frame_id in auto["frame_stats"]
}
return frame_layers, frame_numbers
def score_frame(mdx_keywords: set[str], layers: dict) -> dict:
standalone_total = layers["standalone_keywords"]
standalone_hit = [kw for kw in standalone_total if kw in mdx_keywords]
standalone_score = len(standalone_hit) / len(standalone_total) if standalone_total else 0.0
group_details = []
for group in layers["keyword_groups"]:
hit = [kw for kw in group["keywords"] if kw in mdx_keywords]
coverage = len(hit) / len(group["keywords"]) if group["keywords"] else 0.0
group_details.append(
{
"source_text_raw": group["source_text_raw"],
"keywords": group["keywords"],
"hit_keywords": hit,
"coverage": round(coverage, 3),
}
)
group_score_avg = (
sum(group["coverage"] for group in group_details) / len(group_details)
if group_details
else 0.0
)
group_score_max = max((group["coverage"] for group in group_details), default=0.0)
related_total = layers["related_keywords"]
related_hit = [kw for kw in related_total if kw in mdx_keywords]
related_score = len(related_hit) / len(related_total) if related_total else 0.0
total_score = (
WEIGHT_STANDALONE * standalone_score
+ WEIGHT_GROUP * group_score_avg
+ WEIGHT_RELATED * related_score
)
return {
"standalone": {
"total": standalone_total,
"total_count": len(standalone_total),
"hit": standalone_hit,
"hit_count": len(standalone_hit),
"score": round(standalone_score, 3),
},
"keyword_group": {
"total_count": len(group_details),
"score_avg": round(group_score_avg, 3),
"score_max": round(group_score_max, 3),
"groups": group_details,
},
"related": {
"total_count": len(related_total),
"hit": related_hit,
"hit_count": len(related_hit),
"score": round(related_score, 3),
},
"matching_score": round(total_score, 3),
}
def build_frame_descriptions(auto: dict) -> dict:
"""Frame 번호 → 첫 source_text (설명용)."""
out = {}
for fid, info in auto["frame_stats"].items():
fnum = info["frame_number"]
# 해당 frame 의 source_text_sets 중 index=1 인 것
for set_id, sinfo in auto["source_text_sets"].items():
if sinfo["frame_id"] == fid and sinfo["source_text_index"] == 1:
raw = sinfo["source_text_raw"]
if len(raw) > 35:
raw = raw[:34] + "…"
out[fnum] = raw
break
if fnum not in out:
out[fnum] = f"Frame {fnum}"
return out
def cell_html(rank_row: dict, descriptions: dict, is_answer: bool = False,
is_holdout_top: bool = False) -> str:
"""Top-N 매트릭스 한 칸의 HTML."""
fn = rank_row["frame_number"]
score = rank_row["matching_score"]
desc = descriptions.get(fn, "")
img_src = f"../../data/figma_previews/{fn:02d}.png"
inner = (
f'<img alt="{fn:02d}" src="{img_src}" />'
f'<span class="frame-label"><strong>Frame {fn}</strong> '
f'<span class="score">{score:.3f}</span></span>'
f'<span class="frame-desc">{desc}</span>'
)
if is_answer:
return f'<td class="matrix-cell"><div class="answer-badge">🎯 <b>정답</b><br>{inner}</div></td>'
if is_holdout_top:
return f'<td class="matrix-cell"><div class="holdout-top">{inner}</div></td>'
return f'<td class="matrix-cell">{inner}</td>'
def main() -> None:
auto = yaml.safe_load(INPUT_AUTO.read_text(encoding="utf-8"))
normalized = yaml.safe_load(INPUT_NORMALIZED.read_text(encoding="utf-8"))
frame_layers, frame_numbers = build_frame_layers(auto)
frame_descriptions = build_frame_descriptions(auto)
results = {}
# TARGET + 홀드아웃 모두 처리. ANSWER_MAP 없는 섹션은 answer 없이 Top 만.
all_mdx_sections = list(ANSWER_MAP.keys()) + [
s for s in HOLDOUT_SECTIONS if s not in ANSWER_MAP
]
for mdx_id in all_mdx_sections:
if mdx_id not in normalized["mdx"]:
continue
answer_frame_number = ANSWER_MAP.get(mdx_id) # 홀드아웃은 None
mdx_keywords = set(normalized["mdx"][mdx_id]["unique_tokens"])
per_frame = {}
for frame_id, layers in frame_layers.items():
scored = score_frame(mdx_keywords, layers)
scored["frame_number"] = frame_numbers[frame_id]
per_frame[frame_id] = scored
def rank_by(key_fn):
return sorted(per_frame.items(), key=lambda x: (-key_fn(x[1]), x[0]))
rank_total = rank_by(lambda d: d["matching_score"])
rank_group = rank_by(lambda d: d["keyword_group"]["score_avg"])
rank_standalone = rank_by(lambda d: d["standalone"]["score"])
rank_related = rank_by(lambda d: d["related"]["score"])
def find_rank(ranked_list, frame_number: int) -> int | None:
for idx, (_frame_id, info) in enumerate(ranked_list, start=1):
if info["frame_number"] == frame_number:
return idx
return None
is_holdout = mdx_id in HOLDOUT_SECTIONS
# 홀드아웃 섹션은 answer_frame_number 가 None 이라 answer_ranks 계산 불가 → None 기록
answer_ranks_val = None
if answer_frame_number is not None:
answer_ranks_val = {
"by_matching_score": find_rank(rank_total, answer_frame_number),
"by_keyword_group_score": find_rank(rank_group, answer_frame_number),
"by_standalone_keyword_score": find_rank(rank_standalone, answer_frame_number),
"by_related_keyword_score": find_rank(rank_related, answer_frame_number),
}
results[mdx_id] = {
"mdx_keywords_count": len(mdx_keywords),
"section_type": "holdout" if is_holdout else "target",
"answer_frame_number": answer_frame_number,
"answer_ranks": answer_ranks_val,
"rank_by_matching_score": [
{
"frame_id": frame_id,
"frame_number": info["frame_number"],
"matching_score": info["matching_score"],
"standalone": info["standalone"]["score"],
"keyword_group": info["keyword_group"]["score_avg"],
"related": info["related"]["score"],
}
for frame_id, info in rank_total
],
"rank_by_keyword_group_score": [
{
"frame_id": frame_id,
"frame_number": info["frame_number"],
"score_avg": info["keyword_group"]["score_avg"],
"score_max": info["keyword_group"]["score_max"],
}
for frame_id, info in rank_group
],
"rank_by_standalone_keyword_score": [
{
"frame_id": frame_id,
"frame_number": info["frame_number"],
"score": info["standalone"]["score"],
"hit": info["standalone"]["hit"],
}
for frame_id, info in rank_standalone
],
"rank_by_related_keyword_score": [
{
"frame_id": frame_id,
"frame_number": info["frame_number"],
"score": info["related"]["score"],
"hit_count": info["related"]["hit_count"],
}
for frame_id, info in rank_related
],
"per_frame_detail": per_frame,
}
output = {
"meta": {
"pipeline_step": "6.2",
"description": "MDX 키워드와 프레임별 키워드 매칭 결과 (TARGET + 홀드아웃)",
"score_parts": ["단독 대표 키워드", "대표 키워드 묶음", "연관 키워드"],
"weights": {
"standalone_keyword": WEIGHT_STANDALONE,
"keyword_group": WEIGHT_GROUP,
"related_keyword": WEIGHT_RELATED,
},
"formula": "matching_score = 0.30*standalone + 0.50*keyword_group + 0.20*related",
"answer_map": ANSWER_MAP,
"holdout_sections": HOLDOUT_SECTIONS,
"source_mdx_keywords": "normalized_text_tokens.yaml",
"source_frame_keywords": "auto_anchor_candidates.yaml",
"lock_snapshot": build_lock_snapshot(),
"note": "규칙 기반 점수입니다. 홀드아웃 섹션(01-1/02-1/02-2.1)은 기대 프레임 미지정 — 블라인드 검증용.",
},
"mdx_sections": results,
}
OUT_YAML.write_text(
yaml.safe_dump(output, allow_unicode=True, sort_keys=False, width=300),
encoding="utf-8",
)
md: list[str] = [
"# 키워드 기반 MDX 매칭",
"",
"이 문서는 **키워드 수준** 의 매칭 결과입니다. MDX 구간에서 추출한 키워드와 각 프레임에 적용된 키워드를 비교해, 어떤 프레임이 가장 잘 맞는지 1~3순위로 보여줍니다.",
"",
"_향후 단계에서 의미(semantic) / 구조(structure) / 적용가능성(template fit) 매칭이 추가될 예정입니다._",
"",
"점수는 세 가지 단서를 합산합니다.",
"",
"| 단서 | 의미 | 가중치 |",
"|---|---|---:|",
f"| 단독 대표 키워드 | 특정 프레임을 혼자서도 강하게 가리키는 키워드가 MDX에 얼마나 맞는지 | {WEIGHT_STANDALONE} |",
f"| 대표 키워드 묶음 | 프레임 원문에서 함께 등장한 키워드 조합이 MDX에 얼마나 같이 맞는지 | {WEIGHT_GROUP} |",
f"| 연관 키워드 | 그 외 참고 키워드가 MDX와 얼마나 겹치는지 | {WEIGHT_RELATED} |",
"",
"```text",
f"종합 매칭 점수 = {WEIGHT_STANDALONE} x 단독 대표 키워드 점수",
f" + {WEIGHT_GROUP} x 대표 키워드 묶음 점수",
f" + {WEIGHT_RELATED} x 연관 키워드 점수",
"```",
"",
"### 검증용 정답 프레임",
"",
"| MDX 구간 | 기대 프레임 |",
"|---|---:|",
]
for mdx_id, frame_number in ANSWER_MAP.items():
md.append(f"| {mdx_id} | Frame {frame_number} |")
md += ["", "---", ""]
# ============================================================
# 🖼 Top-3 매트릭스 (이미지 포함, TARGET + 홀드아웃)
# ============================================================
md.append("## 🖼 Top-3 매칭 매트릭스")
md.append("")
md.append("**TARGET 섹션** (ANSWER_MAP 4개): 정답 프레임은 🎯 노란 박스. ")
md.append("**홀드아웃 섹션** (기대 프레임 미지정): 1위는 파란 박스, 해석은 사용자 판단.")
md.append("")
def _matrix_table(section_order, label_fn):
rows = [
'<table><thead><tr><th style="min-width:200px">콘텐츠</th>'
'<th>1순위</th><th>2순위</th><th>3순위</th></tr></thead><tbody>'
]
for mdx_id in section_order:
if mdx_id not in results:
continue
r = results[mdx_id]
top3 = r["rank_by_matching_score"][:3]
label = label_fn(mdx_id, r)
tds = [f'<td style="background:#f1f5f9;text-align:center;font-weight:600"><br>{label}</td>']
for idx, row in enumerate(top3):
is_answer = (r["answer_frame_number"] is not None
and row["frame_number"] == r["answer_frame_number"])
is_hold_top = (r["answer_frame_number"] is None and idx == 0)
tds.append(cell_html(row, frame_descriptions,
is_answer=is_answer,
is_holdout_top=is_hold_top))
rows.append('<tr>' + ''.join(tds) + '</tr>')
rows.append('</tbody></table>')
return '\n'.join(rows)
target_order = ["01-2", "02-2.2", "03-1", "03-2"]
holdout_order = HOLDOUT_SECTIONS
md.append("### TARGET 4 섹션")
md.append("")
md.append(_matrix_table(
target_order,
lambda mid, r: f'<strong>MDX {mid}</strong><br>정답 Frame <strong>{r["answer_frame_number"]}</strong>',
))
md.append("")
md.append("### 🔒 홀드아웃 3 섹션 (블라인드)")
md.append("")
md.append(_matrix_table(
holdout_order,
lambda mid, r: f'<strong>MDX {mid}</strong><br><em>기대 프레임 미지정</em>',
))
md.append("")
md.append("---")
md.append("")
for mdx_id in ["01-2", "02-2.2", "03-1", "03-2"]:
result = results[mdx_id]
ranks = result["answer_ranks"]
answer_frame = result["answer_frame_number"]
md.append(f"## MDX {mdx_id} (기대 프레임: Frame {answer_frame})")
md.append("")
md.append("**기대 프레임의 순위**")
md.append("")
md.append(f"- 종합 매칭 점수 기준: **{ranks['by_matching_score']}위**")
md.append(f"- 대표 키워드 묶음 기준: {ranks['by_keyword_group_score']}위")
md.append(f"- 단독 대표 키워드 기준: {ranks['by_standalone_keyword_score']}위")
md.append(f"- 연관 키워드 기준: {ranks['by_related_keyword_score']}위")
md.append("")
md.append("**종합 매칭 점수 Top 3**")
md.append("")
md.append("| 순위 | 프레임 | 종합 점수 | 단독 대표 키워드 | 대표 키워드 묶음 | 연관 키워드 |")
md.append("|---:|---|---:|---:|---:|---:|")
for idx, row in enumerate(result["rank_by_matching_score"][:3], start=1):
mark = " ✓" if row["frame_number"] == answer_frame else ""
md.append(
f"| **{idx}**{mark} | Frame {row['frame_number']} / {row['frame_id']} | "
f"**{row['matching_score']}** | {row['standalone']} | {row['keyword_group']} | {row['related']} |"
)
md.append("")
md.append("<details><summary>Top 10 전체 보기</summary>")
md.append("")
md.append("| 순위 | 프레임 | 종합 점수 | 단독 대표 키워드 | 대표 키워드 묶음 | 연관 키워드 |")
md.append("|---:|---|---:|---:|---:|---:|")
for idx, row in enumerate(result["rank_by_matching_score"][:10], start=1):
mark = " ✓" if row["frame_number"] == answer_frame else ""
md.append(
f"| {idx}{mark} | Frame {row['frame_number']} / {row['frame_id']} | "
f"{row['matching_score']} | {row['standalone']} | {row['keyword_group']} | {row['related']} |"
)
md.append("")
md.append("</details>")
md.append("")
answer_frame_id = [
frame_id
for frame_id, info in result["per_frame_detail"].items()
if info["frame_number"] == answer_frame
][0]
detail = result["per_frame_detail"][answer_frame_id]
md.append(f"<details><summary>기대 프레임 Frame {answer_frame} / {answer_frame_id} 상세 보기</summary>")
md.append("")
md.append(
f"- 단독 대표 키워드: {detail['standalone']['hit_count']} / {detail['standalone']['total_count']} = {detail['standalone']['score']}"
)
md.append(f" - 맞은 키워드: {', '.join(detail['standalone']['hit']) or '없음'}")
md.append(
f"- 대표 키워드 묶음: 평균 {detail['keyword_group']['score_avg']} / 최고 {detail['keyword_group']['score_max']}"
)
for group in detail["keyword_group"]["groups"]:
terms = ", ".join(group["keywords"])
hits = ", ".join(group["hit_keywords"]) or "없음"
md.append(f" - `{group['source_text_raw']}` → [{terms}] / 맞은 키워드 [{hits}] / 일치율 **{group['coverage']}**")
md.append(
f"- 연관 키워드: {detail['related']['hit_count']} / {detail['related']['total_count']} = {detail['related']['score']}"
)
md.append(f" - 맞은 키워드: {', '.join(detail['related']['hit']) or '없음'}")
md.append(f"- 종합 매칭 점수: **{detail['matching_score']}**")
md.append("")
md.append("</details>")
md.append("")
md.append("---")
md.append("")
# ============================================================
# 홀드아웃 섹션 (블라인드, 기대 프레임 없음)
# ============================================================
md.append("")
md.append("# 🔒 홀드아웃 검증 섹션 (블라인드)")
md.append("")
md.append("아래 섹션들은 **기대 프레임을 사전에 지정하지 않은** 블라인드 검증 대상입니다.")
md.append("현재 파이프라인으로 그대로 돌려 Top 1~3 결과와 점수를 raw 로 기록합니다.")
md.append("결과 해석은 사람이 사후에 수행합니다.")
md.append("")
md.append("### 기준 잠금 스냅샷")
md.append("")
snap = output['meta']['lock_snapshot']
md.append(f"- **실행 시각**: {snap['timestamp']}")
md.append(f"- **가중치**: W1={snap['config']['weights']['W1_standalone']} / "
f"W2={snap['config']['weights']['W2_group']} / "
f"W3={snap['config']['weights']['W3_related']}")
md.append("")
md.append("**파일 지문 (sha256, 앞 16자)**:")
md.append("")
md.append("| 파일 | sha256 |")
md.append("|---|---|")
for name, h in snap['files'].items():
h_short = h[:16] + '...' if h else '(없음)'
md.append(f"| `{name}` | `{h_short}` |")
md.append("")
md.append("**원칙**:")
md.append("")
for p in snap['principle']:
md.append(f"- {p}")
md.append("")
md.append("---")
md.append("")
for mdx_id in HOLDOUT_SECTIONS:
if mdx_id not in results:
continue
result = results[mdx_id]
md.append(f"## 🔒 MDX {mdx_id} (홀드아웃, 기대 프레임 미지정)")
md.append("")
md.append(f"- MDX 키워드 수: {result['mdx_keywords_count']}")
md.append("")
md.append("**Top 3 (기대 프레임 없음, 해석은 사후)**")
md.append("")
md.append("| 순위 | 프레임 | 종합 점수 | 단독 대표 키워드 | 대표 키워드 묶음 | 연관 키워드 |")
md.append("|---:|---|---:|---:|---:|---:|")
for idx, row in enumerate(result["rank_by_matching_score"][:3], start=1):
md.append(
f"| **{idx}** | Frame {row['frame_number']} / {row['frame_id']} | "
f"**{row['matching_score']}** | {row['standalone']} | {row['keyword_group']} | {row['related']} |"
)
md.append("")
md.append("<details><summary>Top 10 전체 보기</summary>")
md.append("")
md.append("| 순위 | 프레임 | 종합 점수 | 단독 대표 키워드 | 대표 키워드 묶음 | 연관 키워드 |")
md.append("|---:|---|---:|---:|---:|---:|")
for idx, row in enumerate(result["rank_by_matching_score"][:10], start=1):
md.append(
f"| {idx} | Frame {row['frame_number']} / {row['frame_id']} | "
f"{row['matching_score']} | {row['standalone']} | {row['keyword_group']} | {row['related']} |"
)
md.append("")
md.append("</details>")
md.append("")
md.append("---")
md.append("")
md_text = "\n".join(md)
OUT_MD.write_text(md_text, encoding="utf-8")
write_html(md_text)
print("산출 완료:")
print(f" yaml: {OUT_YAML}")
print(f" md: {OUT_MD}")
print(f" html: {OUT_HTML}")
print()
print("기준 잠금 스냅샷:")
print(f" 실행시각: {output['meta']['lock_snapshot']['timestamp']}")
for name, h in output['meta']['lock_snapshot']['files'].items():
if h:
print(f" {name:40s} {h[:16]}...")
if __name__ == "__main__":
main()