- 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>
216 lines
7.5 KiB
Python
216 lines
7.5 KiB
Python
"""Pipeline Step 8 — V2: V1 Top-K semantic rerank
|
|
|
|
파이프라인 위치:
|
|
V1 (pipeline_06_2_mdx_matching.py) → **V2 (이 스크립트)** → V3 → V4
|
|
|
|
설계 원칙:
|
|
- V1 결과(mdx_matching_result.yaml) 는 **고정 baseline**. 건드리지 않음.
|
|
- V1 rank_by_matching_score Top-K 만 rerank 대상 — K=5 (기준 잠금).
|
|
- 가중합 아님. **순수 rerank** — V1 점수는 저장만, 재정렬 키는 의미 유사도뿐.
|
|
- AI 판단 없음. ko-sroberta 임베딩은 결정론적 (seed 고정 가정).
|
|
|
|
입력:
|
|
- mdx_matching_result.yaml (V1)
|
|
- MDX_SECTIONS 원문 (pipeline_01 의 MDX_DIR + section config)
|
|
- Figma frame content (phase_common.load_32_frames() → analysis.md '내용')
|
|
|
|
처리:
|
|
1. 각 MDX 섹션(TARGET 4 + Holdout 3)별로:
|
|
- V1 Top-5 frame_id 추출
|
|
- MDX summary = detect_mdx.build_summary (title + 첫 문단 + slot labels)
|
|
- Top-5 frame.content ↔ summary 의 ko-sroberta cosine 계산
|
|
- cosine 내림차순으로 재정렬
|
|
2. lock_snapshot 기록 (V1 yaml + 이 스크립트 + 의존 모듈 sha256)
|
|
|
|
출력:
|
|
- v2_semantic_rerank_result.yaml
|
|
"""
|
|
import hashlib
|
|
import sys
|
|
import datetime
|
|
from pathlib import Path
|
|
import yaml
|
|
|
|
HERE = Path(__file__).parent
|
|
sys.path.insert(0, str(HERE))
|
|
|
|
from phase_common import load_32_frames, load_frame_index
|
|
from detect_mdx import detect_mdx_analysis
|
|
from embeddings import embed_texts, cosine
|
|
from pipeline_01_extract_nodes import MDX_SECTIONS, MDX_DIR
|
|
|
|
TOP_K = 5
|
|
MODEL_ID = 'jhgan/ko-sroberta-multitask'
|
|
|
|
# ============================================================
|
|
# 상수 — lock_snapshot 대상
|
|
# ============================================================
|
|
LOCK_SNAPSHOT_FILES = [
|
|
'pipeline_08_v2_semantic_rerank.py',
|
|
'embeddings.py',
|
|
'detect_mdx.py',
|
|
'phase_common.py',
|
|
'pipeline_01_extract_nodes.py',
|
|
'synonyms.yaml',
|
|
'mdx_matching_result.yaml',
|
|
]
|
|
|
|
|
|
def sha256_file(path: Path) -> str:
|
|
return hashlib.sha256(path.read_bytes()).hexdigest()
|
|
|
|
|
|
def extract_mdx_raw_section(section_id: str) -> tuple[str, str]:
|
|
"""(title, body_text) 반환. title = 첫 줄 heading 마커 제거, body_text = 원문 그대로."""
|
|
cfg = MDX_SECTIONS[section_id]
|
|
p = MDX_DIR / cfg['file']
|
|
lines = p.read_text(encoding='utf-8').split('\n')
|
|
start_idx = None
|
|
for i, ln in enumerate(lines):
|
|
if ln.strip() == cfg['start'].strip():
|
|
start_idx = i
|
|
break
|
|
if start_idx is None:
|
|
raise ValueError(f"start_heading 찾지 못함: {cfg['start']!r} in {p}")
|
|
end_idx = len(lines)
|
|
if cfg.get('end_prefix'):
|
|
for i in range(start_idx + 1, len(lines)):
|
|
if lines[i].strip().startswith(cfg['end_prefix']):
|
|
end_idx = i
|
|
break
|
|
section_lines = lines[start_idx:end_idx]
|
|
raw = '\n'.join(section_lines)
|
|
# title = heading 마커(#, ##, ###) 제거한 첫 줄
|
|
title_line = section_lines[0].lstrip('#').strip()
|
|
return title_line, raw
|
|
|
|
|
|
def main():
|
|
# 1. V1 결과 로드
|
|
v1_path = HERE / 'mdx_matching_result.yaml'
|
|
v1 = yaml.safe_load(v1_path.read_text(encoding='utf-8'))
|
|
|
|
# 2. Figma frame 로드 + content 임베딩 (한 번만)
|
|
frames = load_32_frames()
|
|
idx_data, frame_to_short = load_frame_index()
|
|
fids = list(frames.keys())
|
|
frame_contents = [frames[fid].get('content', '') for fid in fids]
|
|
fid_to_idx = {fid: i for i, fid in enumerate(fids)}
|
|
|
|
print(f"[V2] ko-sroberta 모델 로드 + 32 frame content 임베딩...")
|
|
frame_vecs = embed_texts(frame_contents)
|
|
|
|
# 3. 섹션별 처리
|
|
out_sections = {}
|
|
for sid, sec in v1['mdx_sections'].items():
|
|
top = sec['rank_by_matching_score'][:TOP_K]
|
|
top_fids = [r['frame_id'] for r in top]
|
|
|
|
# MDX summary 생성
|
|
title, raw_text = extract_mdx_raw_section(sid)
|
|
analysis = detect_mdx_analysis(raw_text, title, anchor_vocab=None)
|
|
summary = analysis['summary']
|
|
|
|
# summary 임베딩
|
|
mdx_vec = embed_texts([summary])[0]
|
|
|
|
# Top-K 에 대해서만 cosine 계산
|
|
rerank = []
|
|
for v1_rank_idx, r in enumerate(top, start=1):
|
|
fid = r['frame_id']
|
|
frame_idx = fid_to_idx[fid]
|
|
sem = cosine(mdx_vec, frame_vecs[frame_idx])
|
|
rerank.append({
|
|
'frame_id': fid,
|
|
'frame_number': r['frame_number'],
|
|
'v1_rank': v1_rank_idx,
|
|
'v1_score': r['matching_score'],
|
|
'semantic_score': round(float(sem), 4),
|
|
})
|
|
|
|
# semantic_score 내림차순 재정렬
|
|
rerank.sort(key=lambda x: -x['semantic_score'])
|
|
for new_rank, item in enumerate(rerank, start=1):
|
|
item['v2_rank'] = new_rank
|
|
|
|
out_sections[sid] = {
|
|
'section_type': sec.get('section_type'),
|
|
'answer_frame_number': sec.get('answer_frame_number'),
|
|
'mdx_title': title,
|
|
'mdx_summary': summary,
|
|
'top_k': TOP_K,
|
|
'v1_top_k': [
|
|
{
|
|
'rank': i + 1,
|
|
'frame_id': r['frame_id'],
|
|
'frame_number': r['frame_number'],
|
|
'v1_score': r['matching_score'],
|
|
}
|
|
for i, r in enumerate(top)
|
|
],
|
|
'v2_rerank': rerank,
|
|
}
|
|
|
|
# 4. lock_snapshot
|
|
lock = {
|
|
'timestamp': datetime.datetime.now().isoformat(timespec='seconds'),
|
|
'model': MODEL_ID,
|
|
'top_k': TOP_K,
|
|
'files': {
|
|
name: sha256_file(HERE / name) for name in LOCK_SNAPSHOT_FILES
|
|
},
|
|
'principle': [
|
|
'V1 baseline 고정 — 재정렬 대상만 Top-K 로 한정',
|
|
'가중합 금지 — 순수 semantic rerank',
|
|
'Holdout 성적을 설계 근거로 사용하지 않음',
|
|
],
|
|
}
|
|
|
|
out = {
|
|
'meta': {
|
|
'pipeline_step': '8.v2',
|
|
'description': 'V1 Top-K 후보를 ko-sroberta cosine 으로 재정렬 (캐스케이드 rerank 1단)',
|
|
'model': MODEL_ID,
|
|
'similarity': 'cosine',
|
|
'top_k': TOP_K,
|
|
'mdx_summary_spec': 'title + 첫 일반 문단(≤120자) + slot label Top-5 join (detect_mdx.build_summary)',
|
|
'frame_source': 'analysis.md "내용" 섹션 (phase_common.load_32_frames)',
|
|
'v1_source': 'mdx_matching_result.yaml rank_by_matching_score',
|
|
'holdout_sections': v1['meta']['holdout_sections'],
|
|
'answer_map': v1['meta']['answer_map'],
|
|
'lock_snapshot': lock,
|
|
},
|
|
'mdx_sections': out_sections,
|
|
}
|
|
|
|
out_path = HERE / 'v2_semantic_rerank_result.yaml'
|
|
out_path.write_text(
|
|
yaml.safe_dump(out, allow_unicode=True, sort_keys=False, width=1000),
|
|
encoding='utf-8',
|
|
)
|
|
|
|
# 콘솔 요약
|
|
print()
|
|
print("=" * 70)
|
|
print(f"V2 재정렬 완료: {out_path}")
|
|
print("=" * 70)
|
|
answer_map = v1['meta']['answer_map']
|
|
for sid, s in out_sections.items():
|
|
answer_num = answer_map.get(sid)
|
|
v1_top1 = s['v1_top_k'][0]['frame_number']
|
|
v2_top1 = s['v2_rerank'][0]['frame_number']
|
|
v2_top1_v1rank = s['v2_rerank'][0]['v1_rank']
|
|
mark = ''
|
|
if answer_num is not None:
|
|
v1_ok = '✓' if v1_top1 == answer_num else '✗'
|
|
v2_ok = '✓' if v2_top1 == answer_num else '✗'
|
|
mark = f" 정답={answer_num} V1{v1_ok} V2{v2_ok}"
|
|
else:
|
|
mark = f" (holdout)"
|
|
print(f" [{sid:8}] V1 top1=Frame {v1_top1:>2} → V2 top1=Frame {v2_top1:>2} (V1 rank {v2_top1_v1rank}){mark}")
|
|
print()
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|