"""Phase Z-2 MVP-1.5b — single slide + Type B + frame-derived adapted blocks. 원래 Phase Z 설계 복귀 (멀티-슬라이드 / native-fit 모두 폐기) : - MDX 1 = slide 1 - slide-base → slide-body → layout preset (Type B) → zones[] → frame-derived block (zone-compatible adapt) - frame은 시각 언어 / slot 구성 / 패턴의 source. native geometry 통째 삽입 X. - AI 는 layout / zone / frame / variant 선택에 관여 X — code / catalog 가 결정. MVP-1.5b spec : - 대상 : MDX 03 (회귀) - 출력 : data/runs/{run_id}/phase_z2/final.html (single slide) - AI : 미사용 — MDX → slot_payload 결정론적 매핑 - status : matched_zone only — non-matched 발생 시 abort + error.json - layout : 2 sections → Type B (top + bottom zones) - Frame partials : templates/phase_z2/families/{template_id}.html (Figma 시각 언어 promote, geometry adapt) - Assets : render time copy → data/runs/{run_id}/phase_z2/assets/{template_id}/ 상세 설계 : - docs/architecture/PHASE-Z-CATALOG-RUNTIME-DESIGN.md § 17 (frame-derived partial promotion + zone-compatible adapt) 이전 실험 실패 기록 : - mvp1_test5 : scaffold 임의 — frame 느낌 부재 - mvp1.5_test3 : frame native 통째 — slide 대체 - mvp1.5a_test1 : 멀티-슬라이드 — MDX 1=slide 1 위반 - mvp1.5b_test* : 본 모듈, 원래 설계 라인 합류 """ import json import os import re import shutil import sys import time from dataclasses import asdict, dataclass from pathlib import Path from typing import Optional import yaml from jinja2 import Environment, FileSystemLoader, select_autoescape from phase_z2_composition import ( LAYOUT_PRESETS, CompositionUnit, plan_composition, select_display_strategy_candidates, select_layout_candidates, select_region_layout_candidates, ) from phase_z2_mapper import ( FitError, compute_capacity_fit, get_contract, load_frame_contracts, map_with_contract, ) from phase_z2_classifier import classify_visual_runtime_check from phase_z2_router import route_fit_classification from phase_z2_retry import ( DEFAULT_SAFETY_MARGIN_PX, apply_retry_to_layout_css, plan_zone_ratio_retry, ) from phase_z2_failure_router import enrich_retry_trace_with_failure_classification # trace-only runtime 연결 v0 — B1 → B4 chain. # final.html / mapper / render path 미영향. debug_zones[i].placement_trace 만 기록. from phase_z2_content_extractor import extract_content_objects from phase_z2_placement_planner import plan_placement # ─── Constants ────────────────────────────────────────────────── PROJECT_ROOT = Path(__file__).parent.parent TEMPLATE_DIR = PROJECT_ROOT / "templates" / "phase_z2" ASSETS_SOURCE_BASE = PROJECT_ROOT / "figma_to_html_agent" / "blocks" V4_RESULT_PATH = PROJECT_ROOT / "tests" / "matching" / "v4_full32_result.yaml" RUNS_DIR = PROJECT_ROOT / "data" / "runs" # V4 label → Phase Z status (§ 7.4 매트릭스) V4_LABEL_TO_PHASE_Z_STATUS = { "use_as_is": "matched_zone", "light_edit": "adapt_matched_zone", "restructure": "extract_matched_zone", "reject": "fallback_candidate", } MVP1_ALLOWED_STATUSES = {"matched_zone", "adapt_matched_zone"} # Step 9 v0 (사용자 lock 2026-05-08) — V4 label → application_mode 변환. # tuple = (application_mode, auto_applicable, delegated_to). # status.md §2 Q3 / Q7 lock 따라. APPLICATION_MODE_BY_V4_LABEL = { "use_as_is": ("direct_insert", True, "step10_contract_check"), "light_edit": ("same_frame_with_adjustment", True, "step10_contract_check"), "restructure": ("layout_or_region_change", False, "human_review"), "reject": ("exclude", False, None), } # adapt_matched_zone (V4 light_edit) = frame 구조 동일, 텍스트만 minor edit 필요. # minor edit 정책 (mapper 의무) : # 1. MDX item 수 < frame slot 수 → 빈 slot 그대로 (Jinja2 {% if %} 로 스킵) # 2. MDX item 수 > frame slot 수 → 추가 item 누락 (truncate) — debug.json 에 기록 # 3. 텍스트 길이 mismatch → 그대로 통과 (overflow 는 zone-fit + Selenium check 가 처리) # 4. slot ↔ MDX item 의미 매핑 → 순서 기반 (간단). V4 anchor_match 정교화는 future # AI 호출 X — MVP-1.5b 의 "MDX 1:1 결정론적 매핑" 룰 그대로. # Slide canvas / body geometry — front 기준 정상화 (2026-05-07) # 참조: D:/ad-hoc/kei/design_agent_front/slide-base.html # slide-base.html CSS 와 1:1 일치해야 함 (불일치 시 layout 계산 어긋남) SLIDE_W = 1280 SLIDE_H = 720 SLIDE_BODY_LEFT = 50 SLIDE_BODY_TOP = 76 # 사용자 직설 (divider-body 16px 여백) 2026-05-07 SLIDE_BODY_WIDTH = 1180 # calc(100% - 100px) SLIDE_BODY_HEIGHT = 585 # 사용자 직설 (body-footer 10px 여백) 2026-05-07 SLIDE_FOOTER_HEIGHT = 41 # was 32, front 기준 GRID_GAP = 14 # zone 간격 (사용자 직설 2026-05-07) # zone min-height fallback — contract 에 visual_hints.min_height_px 없을 때 사용. # token-based font (var(--font-body) 11px 등) 기준 최소 가독 높이. DEFAULT_ZONE_MIN_HEIGHT_PX = 100 # content_weight 계산 가중치 CONTENT_WEIGHT_COEFFS = { "text_per_chars": 800, # text_len / 800 = score "top_bullet": 0.4, "nested_bullet": 0.15, "table_bonus": 1.5, "subsection": 0.6, } # ─── Data classes ─────────────────────────────────────────────── @dataclass class MdxSection: section_id: str section_num: int title: str raw_content: str @dataclass class V4Match: section_id: str frame_id: str frame_number: int template_id: str confidence: float label: str def to_phase_z_status(match: V4Match) -> str: return V4_LABEL_TO_PHASE_Z_STATUS.get(match.label, "unknown") # ─── MDX parsing ──────────────────────────────────────────────── def parse_mdx(mdx_path: Path) -> tuple[str, list[MdxSection], Optional[str]]: """basic MDX parser — ## level sections only. V4 무관 (matching artifact 모름). section.raw_content 에 ### sub-section 그대로 포함. V4 granularity 와 align 은 align_sections_to_v4_granularity() 가 처리. """ text = mdx_path.read_text(encoding="utf-8") fm_match = re.match(r"^---\n(.*?)\n---\n", text, re.DOTALL) slide_title = "" if fm_match: fm = yaml.safe_load(fm_match.group(1)) slide_title = fm.get("title", "") text = text[fm_match.end():] footer_match = re.search(r":::note\[[^\]]*\]\n(.*?)\n:::", text, re.DOTALL) footer_text = None if footer_match: body = footer_match.group(1) bullet_match = re.search(r"\*\s*\*\*([^*]+)\*\*", body) footer_text = (bullet_match.group(1).strip() if bullet_match else body.strip()) text = text[:footer_match.start()] + text[footer_match.end():] sections = [] section_pattern = re.compile(r"^##\s+(\d+)\.\s+(.+?)$", re.MULTILINE) matches = list(section_pattern.finditer(text)) mdx_num_match = re.match(r"(\d+)", mdx_path.stem) mdx_id = mdx_num_match.group(1).zfill(2) if mdx_num_match else "00" for i, m in enumerate(matches): section_num = int(m.group(1)) title_text = m.group(2).strip() start = m.end() end = matches[i + 1].start() if i + 1 < len(matches) else len(text) raw_content = text[start:end].strip() sections.append(MdxSection( section_id=f"{mdx_id}-{section_num}", section_num=section_num, title=f"{section_num}. {title_text}", raw_content=raw_content, )) return slide_title, sections, footer_text # ─── V4 lookup ────────────────────────────────────────────────── def load_v4_result() -> dict: return yaml.safe_load(V4_RESULT_PATH.read_text(encoding="utf-8")) def align_sections_to_v4_granularity(sections: list[MdxSection], v4: dict) -> list[MdxSection]: """V4 section granularity 에 맞춰 sections 조정. 각 section 에 대해 : - V4 에 section.section_id 키 있음 → 그대로 유지 (## level 매칭) - V4 에 키 없고 raw_content 에 ### sub-section 존재 → ### 로 drill - V4 에 키 없고 ### 도 없음 → 원본 그대로 (V4 lookup 단계에서 자연스럽게 abort) 설계 원칙 : - parser (parse_mdx) = MDX 만 앎 (V4 무관) - aligner (이 함수) = V4 키 기준 granularity 결정 - runtime parser 가 matching artifact 의 granularity 를 *따라가는* 구조 """ v4_keys = set(v4.get("mdx_sections", {}).keys()) aligned: list[MdxSection] = [] for section in sections: if section.section_id in v4_keys: aligned.append(section) continue # ### drill 시도 sub_pattern = re.compile(r"^###\s+(\d+\.\d+)\s+(.+?)$", re.MULTILINE) sub_matches = list(sub_pattern.finditer(section.raw_content)) if not sub_matches: aligned.append(section) # drill 불가, V4 lookup 에서 abort 됨 continue # ### sub-section 추출 mdx_id = section.section_id.split("-")[0] # e.g., "04" for i, m in enumerate(sub_matches): subnum = m.group(1) # e.g., "2.1" sub_title = m.group(2).strip() start = m.end() end = sub_matches[i + 1].start() if i + 1 < len(sub_matches) else len(section.raw_content) raw = section.raw_content[start:end].strip() aligned.append(MdxSection( section_id=f"{mdx_id}-{subnum}", # e.g., "04-2.1" section_num=section.section_num, title=f"{subnum} {sub_title}", raw_content=raw, )) return aligned def lookup_v4_match(v4: dict, section_id: str) -> Optional[V4Match]: sec = v4.get("mdx_sections", {}).get(section_id) if not sec: return None judgments = sec.get("judgments_full32", []) if not judgments: return None top = judgments[0] return V4Match( section_id=section_id, frame_id=str(top["frame_id"]), frame_number=int(top["frame_number"]), template_id=top["template_id"], confidence=float(top["confidence"]), label=top["label"], ) def lookup_v4_all_judgments(v4: dict, section_id: str) -> list[V4Match]: """V4 raw 32 entry 그대로 반환 — reject 포함, max_n filter 없음. Step 7-A axis 보강 (사용자 lock 2026-05-08) — 사용자 UI 가 모든 frame 의 png 를 보여줄 수 있도록 reject 까지 trace. lookup_v4_candidates 는 변경 없음 (backward compat — non-reject + max_n 만 반환). Returns : list[V4Match] — 0~32 길이. raw judgments_full32 순서 (= V4 score desc) 보존. """ sec = v4.get("mdx_sections", {}).get(section_id) if not sec: return [] judgments = sec.get("judgments_full32", []) out: list[V4Match] = [] for j in judgments: out.append(V4Match( section_id=section_id, frame_id=str(j["frame_id"]), frame_number=int(j["frame_number"]), template_id=j["template_id"], confidence=float(j["confidence"]), label=j["label"], )) return out def lookup_v4_candidates( v4: dict, section_id: str, max_n: int = 6 ) -> list[V4Match]: """V4 non-reject 후보 list 반환 (Step 5 보완 axis — 사용자 lock 2026-05-08). Rule (catalog 와 1:1) : v4_candidates = [ c for c in judgments_full32 if c["label"] != "reject" ][:max_n] Returns: list[V4Match] — 0~max_n 길이. 0 길이 = "no_non_reject_v4_candidate" 신호 (Step 9 fallback path 입력). raw 32 entry 는 tests/matching/v4_full32_result.yaml 에 영속 보존. Backward compat: lookup_v4_match() (rank-1) 는 그대로. Step 6 의 plan_composition() 호출처 무변. 본 함수는 Step 5 artifact + Step 9 application_plan input 위한 새 entry point. """ sec = v4.get("mdx_sections", {}).get(section_id) if not sec: return [] judgments = sec.get("judgments_full32", []) candidates: list[V4Match] = [] for j in judgments: if j.get("label") == "reject": continue candidates.append(V4Match( section_id=section_id, frame_id=str(j["frame_id"]), frame_number=int(j["frame_number"]), template_id=j["template_id"], confidence=float(j["confidence"]), label=j["label"], )) if len(candidates) >= max_n: break return candidates # ─── Content weight + zone layout 계산 ───────────────────────── # layout preset 선택은 phase_z2_composition.select_layout_preset (composition v0) 가 담당. # 본 모듈의 select_layout_preset 은 이전 단순 count-based 구현이었고 dead code 로 제거 (2026-04-29). def compute_content_weight(section: MdxSection) -> dict: """Section 의 콘텐츠 부피 측정 — text/bullet/table/subsection 합성 score.""" text = section.raw_content lines = text.splitlines() text_len = len(text) top_bullets = sum(1 for l in lines if re.match(r"^[\*\-]\s", l)) nested_bullets = sum(1 for l in lines if re.match(r"^\s+[\*\-]\s", l)) has_table = bool(re.search(r"\|[^\n]+\|\n[ \t]*\|[\s\-:|]+\|", text)) subsections = len(re.findall(r"^###\s", text, re.MULTILINE)) c = CONTENT_WEIGHT_COEFFS score = ( text_len / c["text_per_chars"] + top_bullets * c["top_bullet"] + nested_bullets * c["nested_bullet"] + (c["table_bonus"] if has_table else 0) + subsections * c["subsection"] ) return { "score": round(score, 3), "text_length": text_len, "top_bullets": top_bullets, "nested_bullets": nested_bullets, "has_table": has_table, "subsection_count": subsections, } def compute_zone_layout(zones_data: list[dict], total_height: int = SLIDE_BODY_HEIGHT, gap: int = GRID_GAP) -> dict: """zone height 계산 — frame_min_height_px 우선 + 남은 공간 content_weight 비율 분배. Returns dict with per-zone heights + reasoning trace. """ n = len(zones_data) if n == 0: return {"heights_px": [], "ratios": [], "zones": []} available = total_height - gap * (n - 1) # Step 1: 각 zone 의 min_height 할당 — pipeline 가 zones_data 에 frame contract 의 # visual_hints.min_height_px 를 미리 주입했음. 없으면 DEFAULT_ZONE_MIN_HEIGHT_PX. min_heights = [ z.get("min_height_px", DEFAULT_ZONE_MIN_HEIGHT_PX) for z in zones_data ] total_min = sum(min_heights) min_scaled = False if total_min > available: scale = available / total_min min_heights = [int(m * scale) for m in min_heights] total_min = sum(min_heights) min_scaled = True remaining = available - total_min # Step 2: 남은 공간을 content_weight 비율로 분배 weights = [z["content_weight"]["score"] for z in zones_data] total_w = sum(weights) if sum(weights) > 0 else n extras = [int(round(remaining * (w / total_w))) for w in weights] # Step 3: rounding 보정 (마지막 zone 잔여 흡수) heights = [m + e for m, e in zip(min_heights, extras)] diff = available - sum(heights) if diff != 0 and heights: heights[-1] += diff ratios = [round(h / total_height, 3) for h in heights] return { "computation": "min_height_first + content_weight_distribution", "slide_body_height": total_height, "gap": gap, "available_after_gap": available, "min_heights_px": min_heights, "min_scaled": min_scaled, "total_min_height": total_min, "remaining_after_min": remaining, "content_weights": [{"position": z["position"], "template_id": z["template_id"], "score": w} for z, w in zip(zones_data, weights)], "weight_shares": [round(w / total_w, 3) for w in weights], "extras_px": extras, "heights_px": heights, "ratios": ratios, } # Layout preset → zone position 순서 = LAYOUT_PRESETS[preset]["positions"] 직접 사용. # 이전 ZONE_POSITIONS_BY_PRESET (type-b 등 legacy 명) 는 dead code 로 제거 (2026-04-29). def build_layout_css(layout_preset: str, zones_data: list[dict], gap: int = GRID_GAP, override_zone_geometries: Optional[dict[str, dict]] = None) -> dict: """Composition v0 layout preset → CSS grid 문자열. horizontal-2 (= old type-b, 2-zone vertical stack) 만 dynamic heights 유지 (MDX 03 회귀 보존 — content_weight 기반). 다른 preset 은 fr default. Step D-ext (사용자 lock 2026-05-08) — override_zone_geometries (zone_id → {x,y,w,h} slide-body 내부 0~1) 가 들어오면 그 비율로 layout_css 강제. horizontal-2 / vertical-2 만 처리. 다른 preset 은 일단 무시 + warning. 비율 합 != 1 이면 normalize. """ preset = LAYOUT_PRESETS[layout_preset] positions = preset["positions"] # ── Step D-ext : user override 처리 ── if override_zone_geometries: if layout_preset == "horizontal-2": # heights_px override — zone 의 h 비율로 SLIDE_BODY_HEIGHT 분배. ratios = [] for pos in positions: geom = override_zone_geometries.get(pos) ratios.append(float(geom["h"]) if geom else 0.0) total = sum(ratios) if total > 0: heights_px = [int(round(r / total * SLIDE_BODY_HEIGHT)) for r in ratios] rows = " ".join(f"{h}px" for h in heights_px) return { "areas": preset["css_areas"], "cols": preset["css_cols"], "rows": rows, "heights_px": heights_px, "ratios": [round(r / total, 3) for r in ratios], "computation": "user_override_geometry", "dynamic_rows": True, "raw_zone_layout": {"override_applied": True, "source": override_zone_geometries}, } elif layout_preset == "vertical-2": # cols override — zone 의 w 비율로 fr 분배. ratios = [] for pos in positions: geom = override_zone_geometries.get(pos) ratios.append(float(geom["w"]) if geom else 0.0) total = sum(ratios) if total > 0: cols = " ".join(f"{round(r / total * 100, 2)}fr" for r in ratios) return { "areas": preset["css_areas"], "cols": cols, "rows": preset["css_rows"], "heights_px": [], "ratios": [round(r / total, 3) for r in ratios], "computation": "user_override_geometry", "dynamic_rows": False, "raw_zone_layout": {"override_applied": True, "source": override_zone_geometries}, } else: print( f" [override-warning] zone-geometry override 는 layout '{layout_preset}' 미지원 " f"(현재 horizontal-2 / vertical-2 만). default layout_css 사용.", file=sys.stderr, ) if layout_preset == "horizontal-2": zl = compute_zone_layout(zones_data, gap=gap) rows = " ".join(f"{h}px" for h in zl["heights_px"]) return { "areas": preset["css_areas"], "cols": preset["css_cols"], "rows": rows, "heights_px": zl["heights_px"], "ratios": zl["ratios"], "computation": zl["computation"], "dynamic_rows": True, "raw_zone_layout": zl, } return { "areas": preset["css_areas"], "cols": preset["css_cols"], "rows": preset["css_rows"], "heights_px": [], "ratios": [], "computation": "fr_default_from_preset", "dynamic_rows": False, "raw_zone_layout": None, } # ─── Abort ────────────────────────────────────────────────────── def abort_with_error(run_dir: Path, section: MdxSection, match: Optional[V4Match], stage: str, reason: str): error_data = { "section": {"id": section.section_id, "title": section.title}, "frame": { "id": match.frame_id if match else None, "number": match.frame_number if match else None, "template_id": match.template_id if match else None, }, "v4_label": match.label if match else None, "phase_z_status": to_phase_z_status(match) if match else None, "confidence": match.confidence if match else None, "stage": stage, "reason": reason, } run_dir.mkdir(parents=True, exist_ok=True) err_path = run_dir / "error.json" err_path.write_text(json.dumps(error_data, ensure_ascii=False, indent=2), encoding="utf-8") print(f"\n[Phase Z-2 MVP-1.5b] ABORT @ {stage}", file=sys.stderr) print(f" section : {section.section_id} — {section.title}", file=sys.stderr) if match: print(f" frame : {match.frame_id} ({match.template_id})", file=sys.stderr) print(f" status : V4 label '{match.label}' → Phase Z '{to_phase_z_status(match)}'", file=sys.stderr) print(f" reason : {reason}", file=sys.stderr) print(f" error : {err_path}", file=sys.stderr) sys.exit(1) # ─── Slot mapping (catalog-only dispatch) ────────────────────── def _known_contract_ids() -> list[str]: from phase_z2_mapper import load_frame_contracts return list(load_frame_contracts().keys()) def map_mdx_to_slots(section: MdxSection, template_id: str) -> dict: """template_id → slot_payload via catalog contract only. F13/F29/F16 등 모든 frame 의 slot 구조 / cardinality / role / payload builder 는 `templates/phase_z2/catalog/frame_contracts.yaml` 에 선언. legacy hand-coded mapper / MAPPER_BY_TEMPLATE / COLOR_CLASS_BY_KEYWORD / 관련 helper 는 F13/F29/F16 transition (2026-04-29) 후 모두 제거. template_id 가 catalog 에 없으면 ValueError — fallback 없음. 새 frame 추가 = catalog yaml 에 entry 추가 + (필요시) 새 builder/parser 등록. """ contract = get_contract(template_id) if contract is None: raise ValueError( f"No frame_contracts entry for template_id='{template_id}'. " f"Add an entry in templates/phase_z2/catalog/frame_contracts.yaml. " f"Known contracts: {sorted(_known_contract_ids())}." ) return map_with_contract(section, contract) # ─── Asset copy ───────────────────────────────────────────────── def copy_assets(template_id: str, run_dir: Path) -> Optional[Path]: """Frame asset (Figma) 폴더 복사 — frame_id 는 catalog contract 에서 도출. contract 에 `frame_id` 없으면 (asset 없는 frame) None 반환. 이전엔 pipeline.py 에 TEMPLATE_TO_FRAME_ID Python dict 가 있었지만 catalog 로 이전 (2026-04-29). """ contract = get_contract(template_id) frame_id = (contract or {}).get("frame_id") if not frame_id: return None src = ASSETS_SOURCE_BASE / str(frame_id) / "assets" if not src.exists(): return None dst = run_dir / "assets" / template_id dst.parent.mkdir(parents=True, exist_ok=True) if dst.exists(): shutil.rmtree(dst) shutil.copytree(src, dst) return dst # ─── Render (single slide + Type B) ──────────────────────────── def _read_token_css() -> str: token_dir = PROJECT_ROOT / "templates" / "styles" / "tokens" files = ["typography.css", "spacing.css", "colors.css"] parts = [] for f in files: path = token_dir / f if path.exists(): parts.append(f"/* === {f} === */\n{path.read_text(encoding='utf-8')}") return "\n\n".join(parts) def _attempt_zone_ratio_retry( *, run_dir: Path, out_path: Path, slide_title: str, slide_footer: Optional[str], zones_data: list[dict], debug_zones: list[dict], layout_preset: str, layout_css: dict, overflow: dict, fit_classification: dict, router_decision: dict, gap_px: int, ) -> dict: """A3 zone_ratio_retry orchestration. locked rules : - retry budget = 1 - slide-base / spacing / gap 고정 - target zone height 만 증가, sibling donor 에서 같은 양 차감 - donor 룰 strict (visual ok / capacity ok / slack > 0 / min_height 보존) - (b) revert : redistribution fail 또는 rerender 후 visual fail 시 original final.html 그대로 Returns: retry_trace dict (always returned, even when no retry attempted) with : retry_attempted : bool retry_action : 'zone_ratio_retry' or None plan : phase_z2_retry.plan_zone_ratio_retry() 결과 (있을 때만) rerender_attempted : bool retry_passed : bool retry_failure_reason : str or None retried_candidate_path : str or None (rerender 한 경우 진단 artifact 경로) post_retry_overflow : dict (retry_passed=True 일 때만) post_retry_debug_zones : list (retry_passed=True 일 때만 — height_px 갱신본) post_retry_layout_css : dict (retry_passed=True 일 때만) """ base_trace = { "retry_attempted": False, "retry_action": None, "plan": None, "rerender_attempted": False, "retry_passed": False, "retry_failure_reason": None, "retried_candidate_path": None, "safety_margin_px": DEFAULT_SAFETY_MARGIN_PX, "policy": ( "A3 locked rules : retry budget=1, slide-base/spacing/gap fixed, " "donor strict (sibling/visual ok/capacity ok/slack>0/min_height 보존), " "(b) revert on redistribution fail or rerender visual fail." ), } # 1. retry attempt 자체가 적절한지 판단 if not router_decision.get("router_active"): base_trace["retry_skipped_reason"] = "router_active=False (visual check passed — no overflow)" return base_trace proposed = router_decision.get("proposed_actions_summary") or [] if "zone_ratio_retry" not in proposed: base_trace["retry_skipped_reason"] = ( f"zone_ratio_retry not in proposed_actions {proposed} (다른 action category)" ) return base_trace # 2. plan base_trace["retry_attempted"] = True base_trace["retry_action"] = "zone_ratio_retry" plan = plan_zone_ratio_retry( debug_zones=debug_zones, overflow=overflow, fit_classification=fit_classification, router_decision=router_decision, safety_margin_px=DEFAULT_SAFETY_MARGIN_PX, ) base_trace["plan"] = plan if plan is None: base_trace["retry_failure_reason"] = "plan_zone_ratio_retry returned None — no target classification matched zone_ratio_retry" return base_trace if not plan.get("feasible"): # redistribution check 실패 → rerender 안 함, original final.html 그대로 base_trace["retry_failure_reason"] = plan.get("failure_reason") print( f" retry : zone_ratio_retry redistribution INFEASIBLE — " f"target {plan['target_zone_position']} needs {plan['target_added_px']}px, " f"{plan.get('failure_reason')}" ) return base_trace # 3. feasible — apply plan to layout_css, rerender to candidate path (NOT final.html yet) new_layout_css = apply_retry_to_layout_css( layout_css, plan, zones_data, total_height=SLIDE_BODY_HEIGHT, gap_px=gap_px, ) candidate_path = run_dir / "retried_candidate.html" candidate_html = render_slide( slide_title, slide_footer, zones_data, layout_preset, new_layout_css, gap_px=gap_px, ) candidate_path.write_text(candidate_html, encoding="utf-8") base_trace["rerender_attempted"] = True base_trace["retried_candidate_path"] = str(candidate_path.relative_to(PROJECT_ROOT)) print( f" retry : zone_ratio_retry attempted — target {plan['target_zone_position']} " f"+{plan['target_added_px']}px (donor {plan['donor_zone_position']} -{plan['donor_reduced_px']}px) " f"→ rerender to retried_candidate.html → visual check" ) # 4. 후 visual check on candidate candidate_overflow = run_overflow_check(candidate_path) if candidate_overflow.get("passed", False): # 성공 — final.html 을 candidate 로 promote out_path.write_text(candidate_html, encoding="utf-8") # debug_zones height_px / ratio 갱신 (post-retry 상태) new_heights = new_layout_css["heights_px"] new_ratios = new_layout_css["ratios"] post_retry_debug_zones = [] for i, dz in enumerate(debug_zones): new_dz = dict(dz) new_dz["height_px"] = new_heights[i] if i < len(new_heights) else dz.get("height_px") new_dz["ratio"] = new_ratios[i] if i < len(new_ratios) else dz.get("ratio") new_dz["zone_height_post_retry"] = True post_retry_debug_zones.append(new_dz) base_trace["retry_passed"] = True base_trace["post_retry_overflow"] = candidate_overflow base_trace["post_retry_debug_zones"] = post_retry_debug_zones base_trace["post_retry_layout_css"] = new_layout_css print(f" retry : PASSED — final.html promoted to retried version") return base_trace # 5. rerender 후에도 visual fail → (b) revert : final.html 은 original 그대로 (이미 written) base_trace["retry_passed"] = False base_trace["retry_failure_reason"] = ( f"rerender visual_check failed: {candidate_overflow.get('fail_reasons')}. " f"reverting to original final.html (retried_candidate.html stays as diagnostic only)." ) base_trace["candidate_overflow_summary"] = { "passed": False, "fail_reasons": candidate_overflow.get("fail_reasons", []), } print(f" retry : FAILED — candidate visual_check 도 실패. revert to original. ({candidate_path.name} 은 diagnostic 으로 보존)") return base_trace def render_slide(slide_title: str, slide_footer: Optional[str], zones_data: list[dict], layout_preset: str, layout_css: dict, gap_px: int = GRID_GAP) -> str: """Single slide HTML — slide_base.html + 8-preset layout vocabulary. layout_css = build_layout_css() 결과 — areas/cols/rows 문자열 + 동적 heights flag. Template 은 layout_css.{areas,cols,rows} 를 grid CSS 에 직접 주입. """ env = Environment( loader=FileSystemLoader(str(TEMPLATE_DIR)), autoescape=select_autoescape(["html"]), ) for zone in zones_data: partial = env.get_template(f"families/{zone['template_id']}.html") zone["partial_html"] = partial.render(slot_payload=zone["slot_payload"]) base = env.get_template("slide_base.html") return base.render( slide_title=slide_title, slide_footer=slide_footer, zones=zones_data, layout_preset=layout_preset, layout_css=layout_css, gap_px=gap_px, token_css=_read_token_css(), ) # ─── Selenium check (single slide + per-zone) ────────────────── def run_overflow_check(html_path: Path) -> dict: """Single slide + per-zone overflow + clipping check.""" from selenium import webdriver from selenium.webdriver.chrome.options import Options from selenium.webdriver.chrome.service import Service options = Options() options.add_argument("--headless=new") options.add_argument("--no-sandbox") options.add_argument("--disable-dev-shm-usage") options.add_argument("--window-size=1400,900") chromedriver_candidates = [ PROJECT_ROOT / "chromedriver", PROJECT_ROOT / "chromedriver.exe", ] driver = None last_err = None for path in chromedriver_candidates: if path.is_file(): try: driver = webdriver.Chrome(service=Service(str(path)), options=options) break except Exception as e: last_err = e if driver is None: try: driver = webdriver.Chrome(options=options) except Exception as e: return {"passed": False, "error": f"selenium init failed: {last_err or e}"} try: driver.get(html_path.resolve().as_uri()) driver.set_window_size(1400, 900) driver.implicitly_wait(1) result = driver.execute_script(r""" const measure = (el) => ({ clientWidth: el.clientWidth, clientHeight: el.clientHeight, scrollWidth: el.scrollWidth, scrollHeight: el.scrollHeight, excess_x: Math.max(0, el.scrollWidth - el.clientWidth), excess_y: Math.max(0, el.scrollHeight - el.clientHeight), overflowed: (el.scrollWidth > el.clientWidth + 5) || (el.scrollHeight > el.clientHeight + 5), }); const slide = document.querySelector('.slide'); if (!slide) return { error: '.slide not found' }; const slideM = measure(slide); slideM.size_correct = slide.clientWidth === 1280 && slide.clientHeight === 720; const body = document.querySelector('.slide-body'); const bodyM = body ? measure(body) : null; const zones = []; slide.querySelectorAll('.zone').forEach((z) => { const pos = z.getAttribute('data-zone-position') || 'unknown'; const tid = z.getAttribute('data-template-id') || '?'; const m = measure(z); m.position = pos; m.template_id = tid; // 내부 clipping 검사 — frame-family root/cell 단위. // tolerance / threshold 그대로. inner_content_signals 만 추가 보강 (detection 데이터 늘림). const clipped = []; z.querySelectorAll('[class*="f13b"], [class*="f29b"], [class*="f16b"]').forEach((el) => { const dx = el.scrollWidth - el.clientWidth; const dy = el.scrollHeight - el.clientHeight; if (dx > 5 || dy > 5) { // inner content signals — clipped cell 안에 *어떤 종류의 콘텐츠가 들어있는지* 보고. // classifier 가 frame_internal_cell 만 봐서는 부족하니 inner 까지 본다. const inner_signals = []; if (el.querySelector('.transform-block, .transform-row, .transform-rows')) { inner_signals.push('structural_unit'); } if (el.querySelector('table')) { inner_signals.push('tabular'); } if (el.querySelector('.text-line')) { inner_signals.push('text_flow'); } clipped.push({ class_name: el.className, inner_content_signals: inner_signals, excess_x: Math.max(0, dx), excess_y: Math.max(0, dy), clientWidth: el.clientWidth, clientHeight: el.clientHeight, scrollWidth: el.scrollWidth, scrollHeight: el.scrollHeight, }); } }); m.clipped_inner = clipped; zones.push(m); }); // B5 v0 — frame_slot_metrics (per-cell measurement of [data-frame-slot-id]) // 현재 F29 partial 만 marker 보유 (process_column / product_column × 3 cells = 6 entries 기대). // 다른 frame (F13 / F16) 은 marker 미적용 → entry 0 — 정상. const frame_slot_metrics = []; slide.querySelectorAll('[data-frame-slot-id]').forEach((cell) => { const slotId = cell.getAttribute('data-frame-slot-id'); const m2 = measure(cell); const parentZone = cell.closest('.zone'); const zonePos = parentZone ? (parentZone.getAttribute('data-zone-position') || 'unknown') : 'unknown'; const zoneTid = parentZone ? (parentZone.getAttribute('data-template-id') || '?') : '?'; frame_slot_metrics.push({ zone_position: zonePos, zone_template_id: zoneTid, frame_slot_id: slotId, class_name: cell.className, clientWidth: m2.clientWidth, clientHeight: m2.clientHeight, scrollWidth: m2.scrollWidth, scrollHeight: m2.scrollHeight, excess_x: m2.excess_x, excess_y: m2.excess_y, overflowed: m2.overflowed, }); }); return { slide: slideM, slide_body: bodyM, zones, frame_slot_metrics }; """) screenshot_path = html_path.parent / "preview.png" try: driver.save_screenshot(str(screenshot_path)) result["screenshot"] = str(screenshot_path.relative_to(PROJECT_ROOT)) except Exception as e: result["screenshot_error"] = str(e) finally: driver.quit() if "error" in result: return {"passed": False, **result} fail_reasons = [] if not result["slide"]["size_correct"]: fail_reasons.append( f"slide size != 1280x720 (got {result['slide']['clientWidth']}x{result['slide']['clientHeight']})" ) if result["slide"]["overflowed"]: fail_reasons.append( f"slide overflowed by {result['slide']['excess_y']}px (vert) / {result['slide']['excess_x']}px (horiz)" ) if result.get("slide_body") and result["slide_body"]["overflowed"]: fail_reasons.append( f"slide-body overflowed by {result['slide_body']['excess_y']}px (vert)" ) for z in result["zones"]: if z["overflowed"]: fail_reasons.append( f"zone--{z['position']} ({z['template_id']}) overflowed by {z['excess_y']}px (vert) / {z['excess_x']}px (horiz)" ) for c in z.get("clipped_inner", []): fail_reasons.append( f"zone--{z['position']}: inner clipped .{c['class_name']} — " f"excess {c['excess_y']}px vert / {c['excess_x']}px horiz " f"(content {c['scrollHeight']} vs container {c['clientHeight']})" ) result["passed"] = len(fail_reasons) == 0 result["fail_reasons"] = fail_reasons return result def write_overflow_error(run_dir: Path, overflow: dict) -> Path: error_data = { "stage": "visual_runtime_check", "reason": "Visual runtime contract 위반 — slide / slide-body / zone overflow / clipping.", "fail_reasons": overflow.get("fail_reasons", []), "details": overflow, } err_path = run_dir / "error.json" err_path.write_text(json.dumps(error_data, ensure_ascii=False, indent=2), encoding="utf-8") return err_path # ─── Debug.json (single slide + zones[]) ─────────────────────── def compute_slide_status(sections: list[MdxSection], units: list[CompositionUnit], comp_debug: dict, overflow: dict, adapter_needed_units: Optional[list[dict]] = None, debug_zones: Optional[list[dict]] = None) -> dict: """Slide 산출물의 정확한 상태 계산 — 자동 파이프라인 결과 보고. 축 : - rendered : final.html 이 디스크에 쓰였는가 - visual_check_passed : Selenium per-zone overflow / clipping 통과 여부 - full_mdx_coverage : aligned 된 모든 section_id 가 어떤 selected unit 에 의해 covered - adapter_needed_count : mapper FitError 로 자동 렌더 못 한 unit 수 (별 review 개념 X — 자동 실패 보고) - content_truncated_count : builder 가 truncate 한 zone 수 (informational) overall enum : PASS — visual OK + full coverage + adapter_needed=0 RENDERED_WITH_VISUAL_REGRESSION — full coverage 이지만 visual fail PARTIAL_COVERAGE — 일부 section 필터됨, 렌더된 부분만 visual OK PARTIAL_COVERAGE_WITH_VISUAL_REGRESSION — 둘 다 (adapter_needed > 0 시 status note 추가, overall 은 위 enum 사용) """ aligned_ids = [s.section_id for s in sections] covered = set() for u in units: covered.update(u.source_section_ids) filtered_ids = sorted(set(aligned_ids) - covered) full_coverage = len(filtered_ids) == 0 visual_passed = bool(overflow.get("passed", False)) adapter_needed_units = list(adapter_needed_units or []) content_truncated = [] for z in (debug_zones or []): tc = z.get("content_truncated_count") if tc: content_truncated.append({ "position": z["position"], "source_section_ids": z["source_section_ids"], "template_id": z["v4_template_id"], "truncated_count": tc, }) # 필터된 section 의 사유 (auto pipeline 결정 트레이스 — review 개념 X) filtered_section_reasons = [] for c in comp_debug.get("candidates_summary", []): if c.get("selection_state") == "selected": continue cand_ids = c.get("source_section_ids", []) if any(sid in filtered_ids for sid in cand_ids): filtered_section_reasons.append({ "section_ids": cand_ids, "merge_type": c.get("merge_type"), "template_id": c.get("template_id"), "v4_label": c.get("label"), "phase_z_status": c.get("phase_z_status"), "score": c.get("score"), "selection_state": c.get("selection_state"), # filtered_status / filtered_weak / filtered_lost "filter_reasons": c.get("filter_reasons", []), }) if full_coverage and visual_passed: overall = "PASS" elif full_coverage and not visual_passed: overall = "RENDERED_WITH_VISUAL_REGRESSION" elif not full_coverage and visual_passed: overall = "PARTIAL_COVERAGE" else: overall = "PARTIAL_COVERAGE_WITH_VISUAL_REGRESSION" return { "rendered": True, "visual_check_passed": visual_passed, "full_mdx_coverage": full_coverage, "aligned_section_ids": aligned_ids, "covered_section_ids": sorted(covered), "filtered_section_ids": filtered_ids, "filtered_section_reasons": filtered_section_reasons, "visual_fail_reasons": list(overflow.get("fail_reasons") or []), "adapter_needed_count": len(adapter_needed_units), "adapter_needed_units": adapter_needed_units, "content_truncated_count": len(content_truncated), "content_truncated_units": content_truncated, "overall": overall, "note": ( "자동 파이프라인 결과 보고. review/UI 개념 X. final.html 파일명 != PASS 의미. " "overall == PASS 는 visual OK + full coverage + adapter_needed=0 일 때만. " "adapter_needed_count > 0 = mapper 가 contract 와 안 맞아 자동 렌더 못 한 zone 존재. " "content_truncated_count > 0 = builder 가 truncate 한 zone 존재 (rendered 됐지만 일부 콘텐츠 손실)." ), } # ─── Per-step artifact write (locked schema) ──────────────────── # 모든 step JSON 공통 필드: step_num, step_name, step_status, # pipeline_path_connected, input, output, note, data # 사용자 lock — 한 슬라이드 결과물 고치지 말고 시스템 layer 박기 (오답노트 #2) def _write_step_artifact( run_dir: Path, step_num: int, name: str, data, *, step_status: str = "done", pipeline_path_connected: bool = True, inputs: Optional[list[str]] = None, outputs: Optional[list[str]] = None, note: Optional[str] = None, ) -> Path: """Write per-step JSON artifact to {run_dir}/steps/step{NN}_{name}.json. Locked schema (사용자 직설): step_num, step_name, step_status, pipeline_path_connected, input, output, note, data. Status values: 'done' / 'partial' / 'trace-only' / 'future' / 'failed'. """ steps_dir = run_dir / "steps" steps_dir.mkdir(exist_ok=True) fname = f"step{step_num:02d}_{name}.json" fpath = steps_dir / fname payload = { "step_num": step_num, "step_name": name, "step_status": step_status, "pipeline_path_connected": pipeline_path_connected, "input": inputs or [], "output": outputs or [fname], "note": note, "data": data, } fpath.write_text( json.dumps(payload, ensure_ascii=False, indent=2, default=str), encoding="utf-8", ) return fpath def _write_step_html( run_dir: Path, step_num: int, name: str, title: str, body_html: str, *, step_status: str = "done", inputs: Optional[list[str]] = None, outputs: Optional[list[str]] = None, ) -> Path: """Write per-step HTML artifact with locked header (input/output/status). HTML 산출물 = 사용자가 시각으로 판단해야 하는 step (7/8/9/13/20). """ steps_dir = run_dir / "steps" steps_dir.mkdir(exist_ok=True) fname = f"step{step_num:02d}_{name}.html" fpath = steps_dir / fname inputs_lines = "\n".join(f" - {i}" for i in (inputs or [])) outputs_lines = "\n".join(f" - {o}" for o in (outputs or [fname])) status_class = ( "pass" if step_status == "done" else "fail" if step_status == "failed" else "partial" ) inputs_li = "".join(f"
{i}{o}section {_sid}: V4 entry 없음
{_sid}: {_sec.get('mdx_title', '?')}answer_frame_number: {_sec.get('answer_frame_number', '-')}" f" | is_holdout: {_sec.get('is_holdout', '-')}
" f'{_j.get("template_id")})각 zone 의 source section 마다 V4 rank 1~4 후보 표시. " f"녹색 강조 = rank-1 (현재 선택된 frame).
{_candidates_html}" ), step_status="done", inputs=["step05_v4_evidence.json", "tests/matching/v4_full32_result.yaml"], outputs=["step09_frame_selection.json", "step09_frame_matching_candidates.html"], ) # ─── Step 10: Frame Contract 확인 ─── contracts_per_zone = [] for dz in debug_zones: c = get_contract(dz.get("contract_id")) contracts_per_zone.append({ "position": dz["position"], "contract_id": dz.get("contract_id"), "frame_id": (c or {}).get("frame_id"), "family": (c or {}).get("family"), "source_shape": (c or {}).get("source_shape"), "cardinality": (c or {}).get("cardinality"), "visual_hints": (c or {}).get("visual_hints"), "accepted_content_types": (c or {}).get("accepted_content_types"), "sub_zones": (c or {}).get("sub_zones"), "payload_builder": ((c or {}).get("payload") or {}).get("builder"), "payload_builder_options": ((c or {}).get("payload") or {}).get("builder_options"), }) _write_step_artifact( run_dir, 10, "frame_contract", data={"per_zone": contracts_per_zone}, step_status="partial", pipeline_path_connected=True, inputs=["step09_frame_selection.json", "templates/phase_z2/catalog/frame_contracts.yaml"], outputs=["step10_frame_contract.json"], note="full contract dict (sub_zones / cardinality / visual_hints 포함). density envelope 미선언 (별 axis).", ) # ─── Step 11: Slot Mapping (B4 placement, trace-only) ─── _write_step_artifact( run_dir, 11, "slot_mapping", data={ "per_zone": [ { "position": dz["position"], "selected_template_id": (dz.get("placement_trace") or {}).get("selected_template_id"), "slot_assignments": (dz.get("placement_trace") or {}).get("slot_assignments") or [], "rejection": (dz.get("placement_trace") or {}).get("rejection") or [], "overflow_buffer": (dz.get("placement_trace") or {}).get("overflow_buffer") or [], } for dz in debug_zones ], }, step_status="trace-only", pipeline_path_connected=False, inputs=["step04_internal_composition.json", "step10_frame_contract.json"], outputs=["step11_slot_mapping.json"], note="B4 PlacementPlan slot_assignments — render path 미연결. 실제 render slot 매핑은 mapper.py 의 builder.", ) # ─── Step 12: Slot Payload (actual values, mapper.py 결과) ─── _write_step_artifact( run_dir, 12, "slot_payload", data={ "per_zone": [ { "position": zd["position"], "template_id": zd["template_id"], "builder": (get_contract(zd["template_id"]) or {}).get("payload", {}).get("builder"), "slot_payload": zd["slot_payload"], "content_weight": zd.get("content_weight"), "min_height_px": zd.get("min_height_px"), } for zd in zones_data ], }, step_status="done", pipeline_path_connected=True, inputs=["step10_frame_contract.json", "step02_normalized.json"], outputs=["step12_slot_payload.json"], note="map_with_contract 결과 — actual slot_payload 값 그대로 (key 만 X).", ) # 6. Build layout CSS — horizontal-2 = dynamic heights (regression preserve), 그 외 = fr default. # Step D-ext : override_zone_geometries 가 들어오면 layout_css 강제. layout_css = build_layout_css( layout_preset, zones_data, override_zone_geometries=override_zone_geometries ) if layout_css["dynamic_rows"]: for dz, h, r in zip(debug_zones, layout_css["heights_px"], layout_css["ratios"]): dz["height_px"] = h dz["ratio"] = r print(f" zones : heights {layout_css['heights_px']} px, ratios {layout_css['ratios']}") else: print(f" zones : fr default ({layout_css['cols']} / {layout_css['rows']})") # ─── Step 7: Slide-Level Layout (선택된 layout + 후보 list — Step 7-conn) ─── # Step 7-conn axis (사용자 lock 2026-05-08) — select_layout_candidates(unit_count) # 결과를 artifact 에 기록 (passive). default 결정 자체는 무변 (layout_preset 그대로). layout_candidates_list = select_layout_candidates(len(units)) _write_step_artifact( run_dir, 7, "layout", data={ "layout_preset": layout_preset, "layout_css": layout_css, "zones_count": len(zones_data), "unit_count": len(units), "layout_candidates": layout_candidates_list, # Step 7-A axis : user override trace "layout_override_applied": layout_override_applied, "auto_layout_preset": auto_layout_preset, }, step_status="partial", pipeline_path_connected=True, inputs=["step06_composition_plan.json"], outputs=["step07_layout.json", "step07_selected_layout.html"], note=( "count-based v0 — 들여쓰기 / 정렬 미세 layout 미구현 (Step 7 ⚠ partial). " "Step 7-conn (사용자 lock 2026-05-08): unit_count → layout_candidates list " "(select_layout_candidates) artifact 기록. default 결정 자체는 무변 — " "candidates[0] 가 default (default_selection: true), 나머지는 alternative. " "Step 9 application_plan input." ), ) # Step 7 HTML — slide-base 위 실제 zone 박스 위치 시각화 (constants 와 1:1 일치) _slide_w, _slide_h, _body_h = SLIDE_W, SLIDE_H, SLIDE_BODY_HEIGHT _zone_palette = ["#dbeafe", "#fef3c7", "#dcfce7", "#fce7f3", "#ede9fe"] _zone_borders = ["#3b82f6", "#f59e0b", "#16a34a", "#db2777", "#7c3aed"] _grid_template = layout_css.get("rows") or "1fr" _zone_visuals_7 = "" for i, dz in enumerate(debug_zones): _color = _zone_palette[i % len(_zone_palette)] _border = _zone_borders[i % len(_zone_borders)] _zone_visuals_7 += ( f'{dz.get("v4_template_id") or "-"}{lc}{_default_mark}{_badge}source: templates/phase_z2/layouts/layouts.yaml + '
f'select_layout_candidates(unit_count) '
f'(Step 7-B). default = first entry. 현재 single-decision logic 은 '
f'default 만 사용 — Step 9 application_plan 진입 시 후보 평가.
{layout_preset}slide canvas {_slide_w}×{_slide_h}px / slide-body {SLIDE_BODY_WIDTH}×{_body_h}px (left {SLIDE_BODY_LEFT} / top {SLIDE_BODY_TOP}). " f"안 zone 박스 = 실제 비율 그대로.
" f"{_slide_visual_7}" f"{_candidates_block}" f"{json.dumps(layout_css, ensure_ascii=False, indent=2)}"
),
step_status="partial",
inputs=["step06_composition_plan.json"],
outputs=["step07_layout.json", "step07_selected_layout.html"],
)
# ─── Step 8: Zone + Region Ratio (계획값 only — render 전) ───
# Step 8-conn axis (사용자 lock 2026-05-08) — region/display 후보 list per zone (passive 기록).
# placeholder signals (Step 3/4 부재 종속) :
# region_count = 1 (text-only assumption)
# content_type = "text_block"
# flow_type / role_pattern / has_visual_element / details_presence /
# large_table / long_text / fits_in_region = None or False
# Step 3/4 (Content Object 추출 + Internal Composition Planning) 활성화 시
# 이 placeholder 가 실제 content_object 신호로 교체됨 (별 axis).
_step8_placeholder_signals = {
"region_count": 1,
"content_type": "text_block",
"flow_type": None,
"role_pattern": None,
"ratio_asymmetric": False,
"has_visual_element": False,
"details_presence": False,
"large_table": False,
"long_text": False,
"fits_in_region": None,
"_note": (
"Step 3/4 부재 종속 placeholder — 모든 zone 을 text-only / region_count=1 "
"로 가정. 실제 content_object 신호 활성화는 별 axis."
),
}
zone_region_plans = []
for dz in debug_zones:
contract = get_contract(dz.get("contract_id")) or {}
sub_zones = contract.get("sub_zones") or []
cardinality = contract.get("cardinality") or {}
visual_hints = contract.get("visual_hints") or {}
# Step 8-conn — region / display strategy 후보 (placeholder signals).
_region_cands = select_region_layout_candidates(
region_count=_step8_placeholder_signals["region_count"],
flow_type=_step8_placeholder_signals["flow_type"],
role_pattern=_step8_placeholder_signals["role_pattern"],
ratio_asymmetric=_step8_placeholder_signals["ratio_asymmetric"],
has_visual_element=_step8_placeholder_signals["has_visual_element"],
details_presence=_step8_placeholder_signals["details_presence"],
large_table=_step8_placeholder_signals["large_table"],
long_text=_step8_placeholder_signals["long_text"],
)
_display_cands = select_display_strategy_candidates(
content_type=_step8_placeholder_signals["content_type"],
long_text=_step8_placeholder_signals["long_text"],
large_table=_step8_placeholder_signals["large_table"],
fits_in_region=_step8_placeholder_signals["fits_in_region"],
)
zone_region_plans.append({
"position": dz["position"],
"zone_height_px_planned": dz.get("height_px"),
"zone_ratio_planned": dz.get("ratio"),
"min_height_px": visual_hints.get("min_height_px"),
"frame_cardinality_strict": cardinality.get("strict"),
"sub_zones_planned": [
{
"id": sz.get("id"),
"role": sz.get("role"),
"accepts": sz.get("accepts"),
"cardinality_strict": (sz.get("cardinality") or {}).get("strict"),
"partial_target_path": sz.get("partial_target_path"),
}
for sz in sub_zones
],
"child_distribution_note": (
"현재 child zone (sub_zones 안 sections) 균등 분배 — "
"Step 8 region-level ratio ⚠ partial."
),
# Step 8-conn — region/display 후보 (placeholder signals)
"region_layout_candidates": _region_cands,
"display_strategy_candidates": _display_cands,
})
_write_step_artifact(
run_dir, 8, "zone_region_ratios",
data={
"zone_heights_px_planned": layout_css.get("heights_px"),
"zone_ratios_planned": layout_css.get("ratios"),
"per_zone_plan": zone_region_plans,
# Step 8-conn placeholder signals (사람이 한 곳에서 caveat 확인)
"step8_conn_placeholder_signals": _step8_placeholder_signals,
},
step_status="partial",
pipeline_path_connected=True,
inputs=["step07_layout.json", "step10_frame_contract.json"],
outputs=["step08_zone_region_ratios.json", "step08_zone_content_placement.html"],
note=(
"계획값 only. 실측값은 Step 14 (visual_check). "
"zone-level 만 dynamic, region-level (sub_zone 안 sections) 은 균등 분배 (1/1/1). "
"F29 처럼 비균등 콘텐츠 (transform_table + plain) 는 첫 cell 부족 가능 — Step 8 ⚠ partial. "
"Step 8-conn (사용자 lock 2026-05-08): per_zone_plan[i] 에 region_layout_candidates + "
"display_strategy_candidates 추가 (passive 기록). placeholder signals = text-only / "
"region_count=1 (Step 3/4 부재 종속). 실제 content_object 신호 활성화는 별 axis. "
"Step 9 application_plan input."
),
)
# Step 8 HTML — zone + sub_zone + sections 시각 박스 (균등 분배 보임)
# Step 8-conn — region/display 후보 list 도 zone 별 박스 아래 표시.
_zone_visuals_8 = ""
for i, plan in enumerate(zone_region_plans):
_color = _zone_palette[i % len(_zone_palette)]
_border = _zone_borders[i % len(_zone_borders)]
_zone_h = plan['zone_height_px_planned'] or 300
_sub_zones_html = ""
for sz in plan['sub_zones_planned']:
_card = sz['cardinality_strict'] or 1
_accepts = ', '.join(sz['accepts'] or [])
# sub_zone 안에 section 박스 cardinality 만큼 균등 분배
_section_boxes = "".join(
f'{sz["id"]}{plan["child_distribution_note"]}
' f'중요: 이 페이지는 계획값 only. 실제 측정값 (clientHeight / scrollHeight / overflow) 은 "
f"step14_visual_check.json 에서 봄.
각 zone 안에 sub_zones 가 columns 로 나열, 각 sub_zone 안 sections 가 cardinality.strict 만큼 균등 row 분배. " f"F29 process_column 의 첫 section 이 transform_table (AS-IS/TO-BE 표) 인데 균등 분배로 capacity 부족 → 10px overflow 직접 원인.
" f'region_count=1 / content_type="text_block" placeholder 신호로만 산출. '
f'Step 3/4 (Content Object 추출 + Internal Composition Planning) 활성화 시 '
f'실제 content_object 신호로 교체 (별 axis).'
f'{u["current_default_candidate"]}'
if u["current_default_candidate"] else 'null'
)
_layout_pills = " ".join(
f'{lc}{" ★" if k == 0 else ""}'
for k, lc in enumerate(u["layout_candidates"])
)
_region_pills = " ".join(
f'{rc}{" ★" if k == 0 else ""}'
for k, rc in enumerate(u["region_layout_candidates"])
)
_display_pills = " ".join(
f'{ds}{" ★" if k == 0 else ""}'
for k, ds in enumerate(u["display_strategy_candidates"])
)
_app_rows = ""
for k, ac in enumerate(u["application_candidates"]):
_bg, _fg = _mode_color.get(ac["application_mode"], ("#f1f5f9", "#475569"))
_is_default = (k == 0)
_default_mark = (
' current_default'
if _is_default else ''
)
_app_rows += (
f'{ac["template_id"]}{_default_mark}{u["unit_id"]} {_status_badge}'
f'layout_preset (default): {u["layout_preset"]} | '
f'current_default_candidate: {_default_html}
layout_candidates (★ default): {_layout_pills}
' f'region_layout_candidates (★ default, placeholder): {_region_pills}
' f'display_strategy_candidates (★ default, placeholder): {_display_pills}
' f'| template_id | ' f'v4_label | ' f'application_mode | ' f'auto_app. | ' f'delegated_to | ' f'
|---|
units: {len(application_plan_units)} | ' f'units_with_no_v4_candidate: ' f'{len(units_with_no_v4)} ({", ".join(units_with_no_v4) if units_with_no_v4 else "none"})
' ) _write_step_html( run_dir, 9, "application_plan", title="Step 9 v0 — Passive Application Plan", body_html=( f'{_summary_block_9}' f'{_unit_blocks_9}' ), step_status="partial", inputs=[ "step05_v4_evidence.json", "step06_composition_plan.json", "step07_layout.json", "step08_zone_region_ratios.json", ], outputs=["step09_application_plan.json", "step09_application_plan.html"], ) # 7. Render single slide html = render_slide(slide_title, slide_footer, zones_data, layout_preset, layout_css) # 8. Write final.html out_path = run_dir / "final.html" out_path.write_text(html, encoding="utf-8") print(f" html : {out_path}") # ─── Step 13: Render ─── _write_step_artifact( run_dir, 13, "render", data={ "final_html_path": str(out_path), "final_html_size_bytes": out_path.stat().st_size, "render_inputs": { "slide_title": slide_title, "slide_footer": slide_footer, "zones_count": len(zones_data), "layout_preset": layout_preset, }, }, step_status="done", pipeline_path_connected=True, inputs=["step07_layout.json", "step12_slot_payload.json", "templates/phase_z2/slide_base.html", "templates/phase_z2/families/*.html"], outputs=["step13_render.json", "step13_draft_render.html", "final.html"], note="Jinja2 render 결과 = final.html. step13_draft_render.html 은 final.html 동일 복사 (검토용).", ) # Step 13 HTML — final.html 그대로 복사 (검토용) (run_dir / "steps" / "step13_draft_render.html").write_text(html, encoding="utf-8") # 9. Selenium check print(" visual : running per-zone overflow check ...") overflow = run_overflow_check(out_path) # ─── Step 14: Visual Runtime Check (실측값) ─── _write_step_artifact( run_dir, 14, "visual_check", data=overflow, step_status="partial", pipeline_path_connected=True, inputs=["step13_render.json", "final.html"], outputs=["step14_visual_check.json"], note=( "Selenium 실측 — clientHeight / scrollHeight / excess_y / frame_slot_metrics. " "Step 8 의 계획값과 비교 시 어느 cell 이 overflow 했는지 박힘. " "image / table 검사 부재 — Step 14 ⚠ partial." ), ) # 10. fit_classifier v0 (A1) — Selenium 결과 → spec §3 category 분류 layer. # *분류만*. action / router / rerender X. behavior 변경 0. fit_classification = classify_visual_runtime_check(overflow, debug_zones) # ─── Step 15: Fit Classification ─── _write_step_artifact( run_dir, 15, "fit_classification", data=fit_classification, step_status="done", pipeline_path_connected=True, inputs=["step14_visual_check.json"], outputs=["step15_fit_classification.json"], note="A1 — visual_runtime_check 결과를 spec §3 category 로 분류.", ) # 11. overflow_router v0 (A2) — category → proposed_action 매핑 layer. # *매핑까지만*. 실행 / rerender / behavior 변경 X. # classifications 각 entry 에 proposed_action 추가, router_decision summary 반환. router_decision = route_fit_classification(fit_classification) # ─── Step 16: Overflow Router ─── _write_step_artifact( run_dir, 16, "router_decision", data=router_decision, step_status="done", pipeline_path_connected=True, inputs=["step15_fit_classification.json"], outputs=["step16_router_decision.json"], note="A2 — category → proposed_action 매핑.", ) # 11.5 zone_ratio_retry action (A3) — A3 locked rules (사용자 잠금) 그대로. # retry budget = 1, slide-base 고정, donor 룰, (b) revert 정책. retry_trace = _attempt_zone_ratio_retry( run_dir=run_dir, out_path=out_path, slide_title=slide_title, slide_footer=slide_footer, zones_data=zones_data, debug_zones=debug_zones, layout_preset=layout_preset, layout_css=layout_css, overflow=overflow, fit_classification=fit_classification, router_decision=router_decision, gap_px=GRID_GAP, ) # retry 가 *성공* 했으면 overflow / fit_classification / router_decision / debug_zones 를 # post-retry 상태로 갱신 (slide_status 가 새 상태 반영하도록). if retry_trace.get("retry_passed"): overflow = retry_trace["post_retry_overflow"] debug_zones = retry_trace["post_retry_debug_zones"] layout_css = retry_trace["post_retry_layout_css"] # post-retry classifier / router 재실행 — 새 overflow 가 통과면 router_active=False fit_classification = classify_visual_runtime_check(overflow, debug_zones) router_decision = route_fit_classification(fit_classification) # 11.6 retry_failure_classifier + next_action_router (A4 — 분류/매핑만, 실행 X) # retry 실패 시 failure_type 분류 + next_proposed_action 기록 (escalation 후보). enrich_retry_trace_with_failure_classification(retry_trace) # ─── Step 17: Implemented Action (retry) ─── _write_step_artifact( run_dir, 17, "retry_trace", data=retry_trace, step_status=( "failed" if retry_trace.get("retry_attempted") and not retry_trace.get("retry_passed") else "done" if retry_trace.get("retry_passed") else "skipped" ), pipeline_path_connected=True, inputs=["step16_router_decision.json"], outputs=["step17_retry_trace.json"], note="A3 — zone_ratio_retry action only. 다른 actions (layout_adjust / frame_internal_fit 등) 미구현 — Step 17 ⚠ partial.", ) # ─── Step 18: Failure Classification (A4-1) ─── _write_step_artifact( run_dir, 18, "failure_classification", data=retry_trace.get("failure_classification") or {}, step_status="done" if retry_trace.get("failure_classification") else "skipped", pipeline_path_connected=True, inputs=["step17_retry_trace.json"], outputs=["step18_failure_classification.json"], note="A4-1 — retry 실패 시 failure_type 분류.", ) # ─── Step 19: Next Action Proposal (A4-2) ─── _write_step_artifact( run_dir, 19, "next_action", data=retry_trace.get("next_action_proposal") or {}, step_status="partial" if retry_trace.get("next_action_proposal") else "skipped", pipeline_path_connected=False, inputs=["step18_failure_classification.json"], outputs=["step19_next_action.json"], note="A4-2 — failure_type → next_proposed_action 1-D mapping. 실제 action 자체는 미구현 (impl_status=MISSING).", ) # 12. Slide status — 자동 파이프라인 결과 보고 (review/UI 개념 X) slide_status = compute_slide_status( sections, units, comp_debug, overflow, adapter_needed_units=adapter_needed_units, debug_zones=debug_zones, ) # ─── Step 20: Slide Status ─── _write_step_artifact( run_dir, 20, "slide_status", data=slide_status, step_status="done", pipeline_path_connected=True, inputs=["step14_visual_check.json", "step17_retry_trace.json", "step19_next_action.json"], outputs=["step20_slide_status.json", "step20_final_status.html"], note="자동 파이프라인 최종 결과 보고. overall = PASS/RENDERED_WITH_VISUAL_REGRESSION/PARTIAL_COVERAGE 등.", ) # Step 20 HTML — 최종 판정 시각 보고 _overall = slide_status.get("overall", "?") _ov_class = "pass" if "PASS" in _overall else "fail" if "FAIL" in _overall or "REGRESSION" in _overall else "partial" _vfs = slide_status.get("visual_fail_reasons") or [] _vfs_html = ( "없음
" ) _aligned = slide_status.get("aligned_section_ids") or [] _covered = slide_status.get("covered_section_ids") or [] _filtered = slide_status.get("filtered_section_ids") or [] _write_step_html( run_dir, 20, "final_status", title="Final Slide Status", body_html=( f'| rendered | {slide_status.get("rendered")} |
|---|---|
| visual_check_passed | {slide_status.get("visual_check_passed")} |
| full_mdx_coverage | {slide_status.get("full_mdx_coverage")} |
| aligned_section_ids | {_aligned} |
| covered_section_ids | {_covered} |
| filtered_section_ids | {_filtered} |
| adapter_needed_count | {slide_status.get("adapter_needed_count", 0)} |
| content_truncated_count | {slide_status.get("content_truncated_count", 0)} |
{slide_status.get("note", "")}
' ), step_status="done", inputs=["step14_visual_check.json", "step17_retry_trace.json"], outputs=["step20_slide_status.json", "step20_final_status.html"], ) # 13. Debug.json debug_path = write_debug_json( run_dir, layout_preset, debug_zones, layout_css, overflow, composition_debug=comp_debug, slide_status=slide_status, fit_classification=fit_classification, router_decision=router_decision, retry_trace=retry_trace, ) print(f" debug : {debug_path}") # ─── Step 21: Debug Trace ─── _write_step_artifact( run_dir, 21, "debug_index", data={ "debug_json_path": str(debug_path), "debug_json_size_bytes": debug_path.stat().st_size, "placement_trace_recorded_zones": len(debug_zones), "frame_slot_metrics_count": len( (overflow or {}).get("frame_slot_metrics") or (overflow or {}).get("details", {}).get("frame_slot_metrics", []) ), }, step_status="partial", pipeline_path_connected=True, inputs=[], outputs=["step21_debug_index.json", "debug.json"], note="placement_trace per-zone + frame_slot_metrics F29 만 기록. region marker partial 미주입 — Step 21 ⚠ partial.", ) # ─── Step 22: 사용자 확인 / Export (future, UI 영역) ─── _write_step_artifact( run_dir, 22, "user_export", data={"status": "future", "scope": "UI 영역 — 자동 파이프라인 범위 외"}, step_status="future", pipeline_path_connected=False, inputs=[], outputs=["step22_user_export.json"], note="UI / export 단계. 자동 파이프라인 범위 외 — 사용자 직접 확인 / 다운로드.", ) # 13. Status report overall = slide_status["overall"] print(f" status : {overall}") if slide_status["filtered_section_ids"]: print(f" filtered_sections = {slide_status['filtered_section_ids']}") if slide_status["adapter_needed_count"]: print(f" adapter_needed_count = {slide_status['adapter_needed_count']}") if slide_status["content_truncated_count"]: print(f" content_truncated = " f"{[(c['position'], c['truncated_count']) for c in slide_status['content_truncated_units']]}") if not slide_status["visual_check_passed"]: for r in (overflow.get("fail_reasons") or [])[:3]: print(f" visual_fail = {r}") # fit_classifier + router 결과 요약 if not fit_classification["visual_check_passed"]: cats = fit_classification["categories_seen"] print(f" fit_categories = {cats}") if router_decision["router_active"]: actions = router_decision["proposed_actions_summary"] status = router_decision["implementation_status_summary"] missing = router_decision["missing_actions_pending_impl"] print(f" proposed_actions = {actions}") print(f" impl_status_summary = {status}") if missing: print(f" missing_actions = {missing} (현재 미구현 → abort)") # retry 결과 요약 (A3) + failure classification / next action proposal (A4) if retry_trace.get("retry_attempted"): print(f" retry_action = {retry_trace['retry_action']}") print(f" retry_passed = {retry_trace['retry_passed']}") if not retry_trace["retry_passed"]: print(f" retry_failure = {retry_trace.get('retry_failure_reason')}") fc = retry_trace.get("failure_classification") or {} nap = retry_trace.get("next_action_proposal") or {} if fc.get("failure_type"): print(f" failure_type = {fc['failure_type']}") if nap.get("next_proposed_action"): print(f" next_proposed_action = {nap['next_proposed_action']} " f"(impl_status={nap.get('next_action_implementation_status')})") # 13. Exit 정책 — visual fail 은 abort, partial coverage 는 abort 안 하지만 PASS 도 아님 if not slide_status["visual_check_passed"]: err_path = write_overflow_error(run_dir, overflow) print(f"\n[Phase Z-2 MVP-1.5b] FAIL @ visual_runtime_check ({overall})", file=sys.stderr) for reason in overflow.get("fail_reasons", [overflow.get("error", "unknown")]): print(f" - {reason}", file=sys.stderr) print(f" error : {err_path}", file=sys.stderr) sys.exit(1) if not slide_status["full_mdx_coverage"]: print( f"\n[Phase Z-2 MVP-1.5b] PARTIAL — visual check OK 지만 " f"sections {slide_status['filtered_section_ids']} 가 composition planner 에서 " f"필터됨 (allowed_statuses 미통과). final.html 은 viable units 만 렌더된 " f"partial artifact. full MDX slide 아님." ) return out_path print( f"\n[Phase Z-2 MVP-1.5b] {overall} — visual check OK + full MDX coverage. " f"최종 사용자 브라우저 검증 후 ship 가능." ) return out_path if __name__ == "__main__": import argparse parser = argparse.ArgumentParser( prog="python -m src.phase_z2_pipeline", description="Phase Z-2 MVP-1.5b pipeline (MDX → 1 slide HTML).", ) parser.add_argument("mdx_path", type=Path, help="MDX 파일 경로") parser.add_argument( "run_id", nargs="?", default=None, help="run_id (출력 디렉토리 이름). 없으면 자동 생성 (basename + timestamp).", ) parser.add_argument( "--override-layout", dest="override_layout", default=None, metavar="PRESET", help=( "layout_preset 강제 (8 preset 중 하나 — single, horizontal-2, vertical-2, " "top-1-bottom-2, top-2-bottom-1, left-1-right-2, left-2-right-1, grid-2x2). " "없으면 자동 결정 (count-based v0)." ), ) parser.add_argument( "--override-frame", dest="override_frames", action="append", default=[], metavar="UNIT_ID=TEMPLATE_ID", help=( "unit_id 의 frame template 강제 변경. UNIT_ID 는 \"+\".join(source_section_ids) " "(e.g., 03-1 또는 03-1+03-2). multiple 가능: --override-frame 03-1=foo " "--override-frame 03-2=bar" ), ) parser.add_argument( "--override-zone-geometry", dest="override_zone_geometries", action="append", default=[], metavar="ZONE_ID=X,Y,W,H", help=( "zone position (top/bottom/left/right/...) 의 slide-body 내부 비율 (0~1) " "강제. horizontal-2 / vertical-2 만 지원. multiple 가능: " "--override-zone-geometry top=0,0,1,0.3 --override-zone-geometry bottom=0,0.3,1,0.7" ), ) args = parser.parse_args() overrides_frames: dict[str, str] = {} for ov in args.override_frames: if "=" not in ov: print( f"[error] --override-frame must be UNIT_ID=TEMPLATE_ID, got: '{ov}'", file=sys.stderr, ) sys.exit(2) k, v = ov.split("=", 1) overrides_frames[k.strip()] = v.strip() overrides_geoms: dict[str, dict] = {} for ov in args.override_zone_geometries: if "=" not in ov: print(f"[error] --override-zone-geometry must be ZONE_ID=X,Y,W,H, got: '{ov}'", file=sys.stderr) sys.exit(2) zid, vals = ov.split("=", 1) parts = vals.split(",") if len(parts) != 4: print(f"[error] --override-zone-geometry expects 4 floats X,Y,W,H, got: '{vals}'", file=sys.stderr) sys.exit(2) try: x, y, w, h = (float(p) for p in parts) except ValueError: print(f"[error] --override-zone-geometry floats parse fail: '{vals}'", file=sys.stderr) sys.exit(2) overrides_geoms[zid.strip()] = {"x": x, "y": y, "w": w, "h": h} run_phase_z2_mvp1( args.mdx_path, args.run_id, override_layout=args.override_layout, override_frames=overrides_frames or None, override_zone_geometries=overrides_geoms or None, )