Files
C.E.L_Slide_test2/src/phase_z2_pipeline.py
2026-05-08 18:06:06 +09:00

2832 lines
128 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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"<li><code>{i}</code></li>" for i in (inputs or []))
outputs_li = "".join(f"<li><code>{o}</code></li>" for o in (outputs or [fname]))
full_html = f"""<!--
Step {step_num:02d}: {title}
Status: {step_status}
Input:
{inputs_lines}
Output:
{outputs_lines}
-->
<!DOCTYPE html>
<html lang="ko"><head>
<meta charset="UTF-8">
<title>Step {step_num:02d}{title}</title>
<style>
body {{ font-family: 'Pretendard', 'Noto Sans KR', sans-serif; padding: 24px; max-width: 1280px; margin: 0 auto; color: #1e293b; }}
h1 {{ border-bottom: 2px solid #333; padding-bottom: 8px; margin-top: 0; }}
h2 {{ margin-top: 32px; }}
.meta {{ background: #f8fafc; padding: 16px; border-left: 4px solid #2563eb; margin-bottom: 24px; }}
.meta ul {{ margin: 4px 0; padding-left: 20px; }}
.meta li {{ margin-bottom: 2px; }}
.pass {{ color: #16a34a; font-weight: 700; }}
.fail {{ color: #dc2626; font-weight: 700; }}
.partial {{ color: #ca8a04; font-weight: 700; }}
table {{ border-collapse: collapse; width: 100%; margin-top: 12px; }}
th, td {{ border: 1px solid #cbd5e1; padding: 8px 12px; text-align: left; vertical-align: top; }}
th {{ background: #f1f5f9; }}
code {{ background: #f1f5f9; padding: 2px 6px; border-radius: 3px; font-size: 0.9em; }}
.grid-preview {{ border: 2px solid #cbd5e1; padding: 8px; margin-top: 12px; }}
.zone-box {{ background: #e2e8f0; border: 1px solid #94a3b8; padding: 12px; margin: 4px 0; border-radius: 4px; }}
.candidate-card {{ border: 1px solid #cbd5e1; padding: 12px; margin: 8px 0; border-radius: 4px; }}
.candidate-rank1 {{ border-left: 4px solid #16a34a; background: #f0fdf4; }}
</style>
</head><body>
<h1>Step {step_num:02d}{title}</h1>
<div class="meta">
<strong>Status:</strong> <span class="{status_class}">{step_status}</span><br>
<strong>Input:</strong>
<ul>{inputs_li}</ul>
<strong>Output:</strong>
<ul>{outputs_li}</ul>
</div>
{body_html}
</body>
</html>"""
fpath.write_text(full_html, encoding="utf-8")
return fpath
def write_debug_json(run_dir: Path, layout_preset: str,
debug_zones: list[dict],
layout_css: dict,
visual_runtime_check: Optional[dict] = None,
composition_debug: Optional[dict] = None,
slide_status: Optional[dict] = None,
fit_classification: Optional[dict] = None,
router_decision: Optional[dict] = None,
retry_trace: Optional[dict] = None) -> Path:
debug = {
"v4_source": str(V4_RESULT_PATH.relative_to(PROJECT_ROOT)),
"v4_label_to_phase_z_status": V4_LABEL_TO_PHASE_Z_STATUS,
"mvp1_allowed_statuses": sorted(MVP1_ALLOWED_STATUSES),
"mode": "composition_v0_layout_8preset",
"mode_note": (
"MVP-1.5b w/ composition planner v0 — sections → candidates (separate / "
"parent_merged) → score → greedy select → 8-preset layout vocabulary "
"(single / horizontal-2 / vertical-2 / top-1-bottom-2 / top-2-bottom-1 / "
"left-1-right-2 / left-2-right-1 / grid-2x2). v0 layout = count-based; "
"v1 axes (cardinality_fit / hierarchy_coherence / density_score) 추후."
),
"layout_preset": layout_preset,
"layout_css": layout_css,
"slide_status": slide_status,
"fit_classification": fit_classification,
"router_decision": router_decision,
"retry_trace": retry_trace,
"composition_planner_debug": composition_debug,
"zones": debug_zones,
"visual_runtime_check": visual_runtime_check,
}
debug_path = run_dir / "debug.json"
debug_path.write_text(json.dumps(debug, ensure_ascii=False, indent=2), encoding="utf-8")
return debug_path
# ─── Main entry ────────────────────────────────────────────────
def run_phase_z2_mvp1(
mdx_path: Path,
run_id: Optional[str] = None,
*,
override_layout: Optional[str] = None,
override_frames: Optional[dict[str, str]] = None,
override_zone_geometries: Optional[dict[str, dict]] = None,
) -> Path:
"""MVP-1.5b entry — single slide + composition planner v0 + 8 preset vocabulary.
Pipeline :
parse_mdx → align_sections_to_v4_granularity → plan_composition →
mapper per unit → render slide_base + frame partial → Selenium check
User overrides (Step 7-A axis, 2026-05-08) :
override_layout : 자동 결정된 layout_preset 을 사용자 선택값으로 강제 (8 preset 중 하나).
override_frames : {unit_id: template_id} — 자동 결정된 frame template 을 사용자 선택값
으로 강제. unit_id = "+".join(source_section_ids) (e.g., "03-1"
또는 "03-1+03-2"). 매칭 unit 의 v4_candidates 에 있는 entry 면
그 entry 의 score / label 도 함께 갱신. 없으면 template_id 만 변경.
"""
mdx_path = Path(mdx_path)
if run_id is None:
run_id = time.strftime("%Y%m%d_%H%M%S") + "_phase_z2"
run_dir = RUNS_DIR / run_id / "phase_z2"
run_dir.mkdir(parents=True, exist_ok=True) # step artifacts 위해 미리 생성
print(f"[Phase Z-2 MVP-1.5b] start — mdx={mdx_path.name}, run_id={run_id}")
# ─── Step 0: 사전 준비 (precondition snapshot) ───
_write_step_artifact(
run_dir, 0, "preconditions",
data={
"v4_source": str(V4_RESULT_PATH.relative_to(PROJECT_ROOT)),
"templates_dir": str(TEMPLATE_DIR.relative_to(PROJECT_ROOT)),
"assets_source_base": str(ASSETS_SOURCE_BASE.relative_to(PROJECT_ROOT)),
"frame_contracts_loaded": len(load_frame_contracts()),
"frame_contracts_template_ids": sorted(load_frame_contracts().keys()),
"v4_label_to_phase_z_status": V4_LABEL_TO_PHASE_Z_STATUS,
"mvp1_allowed_statuses": sorted(MVP1_ALLOWED_STATUSES),
},
step_status="partial",
pipeline_path_connected=True,
inputs=[
"templates/phase_z2/catalog/frame_contracts.yaml",
"tests/matching/v4_full32_result.yaml",
"templates/phase_z2/families/*.html",
"figma_to_html_agent/blocks/",
],
outputs=["step00_preconditions.json"],
note=(
"frame_contracts.yaml 에 등록된 frame 만 mapping 가능. "
"V4 결과의 32 frame 중 다수 (F11/F14/F18 등) 미등록 — Step 0 ⚠ partial."
),
)
# ─── Step 1: MDX 업로드 ───
mdx_source_text = mdx_path.read_text(encoding="utf-8")
(run_dir / "steps").mkdir(exist_ok=True)
(run_dir / "steps" / "step01_mdx_source.md").write_text(mdx_source_text, encoding="utf-8")
_write_step_artifact(
run_dir, 1, "mdx_upload",
data={
"mdx_path": str(mdx_path),
"run_id": run_id,
"run_dir": str(run_dir),
"mdx_source_size_bytes": len(mdx_source_text.encode("utf-8")),
"mdx_source_lines": mdx_source_text.count("\n") + 1,
},
step_status="done",
pipeline_path_connected=True,
inputs=[str(mdx_path)],
outputs=["step01_mdx_upload.json", "step01_mdx_source.md"],
note="MDX 원본 그대로 step01_mdx_source.md 에 복사.",
)
# 1. Parse MDX (V4 무관)
slide_title, sections, slide_footer = parse_mdx(mdx_path)
print(f" parsed : title='{slide_title}', sections={len(sections)} "
f"({[s.section_id for s in sections]}), footer={'yes' if slide_footer else 'no'}")
# ─── Step 2: MDX 정규화 ───
# orphans / details 필드는 schema lock — 빈 배열이라도 박혀야
# "검사 안 함" vs "없음" 구분 가능 (사용자 직설 2026-05-07).
# 실제 orphan / details 감지 로직은 별 axis (Step 2 보강).
_write_step_artifact(
run_dir, 2, "normalized",
data={
"slide_title": slide_title,
"slide_footer": slide_footer,
"sections_count": len(sections),
"sections": [
{
"section_id": s.section_id,
"section_num": s.section_num,
"title": s.title,
"raw_content_length": len(s.raw_content),
"raw_content": s.raw_content,
}
for s in sections
],
"orphans": [], # schema lock — 중목차에 안 속한 텍스트 (감지 미구현)
"details": [], # schema lock — <details> 팝업 콘텐츠 (감지 미구현)
},
step_status="partial",
pipeline_path_connected=True,
inputs=["step01_mdx_source.md"],
outputs=["step02_normalized.json"],
note=(
"parse_mdx 결과: title / sections / footer 분리 + raw_content 보존. "
"heading tree 미생성, orphan / details 감지 미완 (Step 2 ⚠ partial — 별 axis). "
"orphans / details 필드는 schema lock — 빈 배열이라도 'detection 미수행' marker."
),
)
# 2. Load V4
v4 = load_v4_result()
# 3. Align sections to V4 granularity (### drill if needed)
sections = align_sections_to_v4_granularity(sections, v4)
print(f" aligned : sections={len(sections)} ({[s.section_id for s in sections]})")
# ─── Step 5: V4 매칭 evidence (non-reject max-6 후보 list — 사용자 lock 2026-05-08) ───
v4_evidence_list = []
for s in sections:
candidates = lookup_v4_candidates(v4, s.section_id)
v4_evidence_list.append({
"section_id": s.section_id,
"v4_candidates": [
{
"template_id": c.template_id,
"frame_id": c.frame_id,
"frame_number": c.frame_number,
"confidence": c.confidence,
"label": c.label,
}
for c in candidates
],
"candidate_status": "ok" if candidates else "no_non_reject_v4_candidate",
})
_write_step_artifact(
run_dir, 5, "v4_evidence",
data={
"v4_source": str(V4_RESULT_PATH.relative_to(PROJECT_ROOT)),
"aligned_section_ids": [s.section_id for s in sections],
"evidence_per_section": v4_evidence_list,
},
step_status="done",
pipeline_path_connected=True,
inputs=["step02_normalized.json", "tests/matching/v4_full32_result.yaml"],
outputs=["step05_v4_evidence.json"],
note=(
"V4 non-reject max-6 후보 list (Step 9 application_plan input). "
"raw 32 entry 는 tests/matching/v4_full32_result.yaml 에 영속. "
"candidate_status='ok' = 후보 1개 이상 / 'no_non_reject_v4_candidate' = "
"0개 (Step 9 fallback path 입력). "
"Step 6 plan_composition() 은 lookup_v4_match() (rank-1) 그대로 사용 — "
"backward compat (Step 6-A axis 까지)."
),
)
# 4. Composition planner v0 — replaces per-section + select_layout_preset.
# candidate (separate / parent_merged) → score → greedy non-overlapping select →
# layout preset (count-based v0).
def lookup_fn(sid: str) -> Optional[V4Match]:
return lookup_v4_match(v4, sid)
# Step 6-A axis (사용자 lock 2026-05-08) — V4 raw dict 흡수 fn.
# composition module 은 V4 yaml shape 모름. 본 fn 만 통해 후보 list 받음.
def candidates_lookup_fn(sid: str) -> list[V4Match]:
return lookup_v4_candidates(v4, sid)
units, layout_preset, comp_debug = plan_composition(
sections, lookup_fn, V4_LABEL_TO_PHASE_Z_STATUS, MVP1_ALLOWED_STATUSES,
capacity_fit_fn=compute_capacity_fit,
v4_candidates_lookup_fn=candidates_lookup_fn,
)
# ── Step 7-A axis : layout override ──
# 사용자가 LayoutPanel 에서 다른 preset 을 선택했을 때 자동 결정값을 강제 변경.
# 길이 mismatch (positions count vs unit count) 는 zone loop 의 fallback (zone_{i})
# 으로 처리됨. 알 수 없는 preset 이면 ValueError.
auto_layout_preset = layout_preset
layout_override_applied = False
if override_layout is not None and override_layout != layout_preset:
if override_layout not in LAYOUT_PRESETS:
raise ValueError(
f"--override-layout '{override_layout}' is not a known preset. "
f"Available: {sorted(LAYOUT_PRESETS.keys())}"
)
print(
f" [override] layout_preset: {layout_preset}{override_layout}",
file=sys.stderr,
)
layout_preset = override_layout
layout_override_applied = True
if not units or layout_preset is None:
# composition planner 결과 = 0 units. Sections 가 모두 V4 lookup 실패 또는
# status filter 통과 못 함. error.json 기록 후 abort.
run_dir.mkdir(parents=True, exist_ok=True)
error_data = {
"stage": "composition_planner",
"reason": (
"Composition planner v0 selected 0 viable units. "
f"Either no V4 entries for any section, or all candidates filtered out by "
f"allowed_statuses={sorted(MVP1_ALLOWED_STATUSES)}."
),
"aligned_section_ids": [s.section_id for s in sections],
"composition_debug": comp_debug,
}
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 @ composition_planner", file=sys.stderr)
print(f" reason : 0 viable units after composition v0", file=sys.stderr)
print(f" error : {err_path}", file=sys.stderr)
sys.exit(1)
print(f" preset : {layout_preset} ({len(units)} units, composition v0 count-based)")
for u in units:
print(f" unit : {u.source_section_ids} merge={u.merge_type}"
f"frame {u.frame_number} ({u.frame_template_id}) "
f"label={u.label} score={u.score:.3f}")
# ─── Step 6: Composition Planning ───
_write_step_artifact(
run_dir, 6, "composition_plan",
data={
"selected_units_count": len(units),
"layout_preset_decided": layout_preset,
"candidates_summary": comp_debug.get("candidates_summary"),
"candidates_total": comp_debug.get("candidates_total"),
"candidates_viable_auto": comp_debug.get("candidates_viable_auto"),
"selected_units": [
{
"source_section_ids": u.source_section_ids,
"merge_type": u.merge_type,
"frame_id": u.frame_id,
"frame_number": u.frame_number,
"frame_template_id": u.frame_template_id,
"label": u.label,
"score": u.score,
"phase_z_status": u.phase_z_status,
"rationale": u.rationale,
"notes": list(u.notes),
# Step 6-A axis (사용자 lock 2026-05-08) — V4 후보 list.
# 단일 frame_* / label / confidence 와 일관 (candidates[0] = rank-1 non-reject).
"v4_candidates": [
{
"template_id": c.template_id,
"frame_id": c.frame_id,
"frame_number": c.frame_number,
"confidence": c.confidence,
"label": c.label,
}
for c in u.v4_candidates
],
}
for u in units
],
},
step_status="done",
pipeline_path_connected=True,
inputs=["step02_normalized.json", "step05_v4_evidence.json"],
outputs=["step06_composition_plan.json"],
note=(
"composition v0 count-based — sections → candidates → score → greedy select. "
"Step 6-A (사용자 lock 2026-05-08): selected_units[i].v4_candidates 추가 "
"(non-reject max-6 후보 list, candidates[0] = 단일 frame_* 와 일관). "
"logic 무변 — runtime 결과 동일. Step 9 application_plan input."
),
)
# 5. Per-unit: synthesize MdxSection → mapper → assets → zone data
# mapper FitError 는 catch — 자동 파이프라인은 다른 zone 계속 진행. abort X.
positions = LAYOUT_PRESETS[layout_preset]["positions"]
zones_data = []
debug_zones = []
adapter_needed_units: list[dict] = []
# ── Step 7-A axis : frame override ──
# {unit_id: template_id} 형식. unit_id 매칭 시 unit.frame_template_id 강제 변경.
# v4_candidates 안에서 같은 template_id 를 가진 entry 를 찾으면 frame_id /
# frame_number / confidence / label 까지 그 entry 에서 가져와 갱신 — 그래야 step09
# artifact 의 메타가 일관됨.
# frame contract 가 catalog 에 등록 안 된 template_id 면 skip + warning —
# crash 방지 (V4 score 는 매겨지지만 catalog partial 은 없는 후보 존재).
frame_overrides_applied: list[dict] = []
frame_overrides_skipped: list[dict] = []
if override_frames:
for unit in units:
unit_id = "+".join(unit.source_section_ids)
if unit_id not in override_frames:
continue
new_tid = override_frames[unit_id]
old_tid = unit.frame_template_id
if new_tid == old_tid:
continue
# catalog contract 존재 확인 — 없으면 override 거부.
new_contract = get_contract(new_tid)
if new_contract is None:
frame_overrides_skipped.append({
"unit_id": unit_id,
"from": old_tid,
"to": new_tid,
"reason": "no_frame_contract_in_catalog",
})
print(
f" [override-skip] unit {unit_id}: '{new_tid}' has no entry in "
f"frame_contracts catalog — keeping {old_tid}",
file=sys.stderr,
)
continue
match = None
for cand in (unit.v4_candidates or []):
if getattr(cand, "template_id", None) == new_tid:
match = cand
break
if match is not None:
unit.frame_template_id = match.template_id
unit.frame_id = match.frame_id
unit.frame_number = match.frame_number
unit.confidence = match.confidence
unit.label = match.label
meta_source = "v4_candidates"
else:
unit.frame_template_id = new_tid
meta_source = "raw_template_id_only"
frame_overrides_applied.append({
"unit_id": unit_id,
"from": old_tid,
"to": new_tid,
"meta_source": meta_source,
})
print(
f" [override] unit {unit_id} frame: {old_tid}{new_tid} "
f"({meta_source})",
file=sys.stderr,
)
for i, unit in enumerate(units):
position = positions[i] if i < len(positions) else f"zone_{i}"
synth_section = MdxSection(
section_id="+".join(unit.source_section_ids),
section_num=0,
title=unit.title,
raw_content=unit.raw_content,
)
contract = get_contract(unit.frame_template_id)
builder_name = contract["payload"].get("builder")
visual_hints = contract.get("visual_hints") or {}
min_height_px = visual_hints.get("min_height_px", DEFAULT_ZONE_MIN_HEIGHT_PX)
contract_frame_id = contract.get("frame_id")
# ─── trace-only runtime 연결 v0 — B1 → B4 chain (final.html 영향 X) ───
# B1~B4 의 dormant chain 을 *real MDX runtime data* 로 처음 호출.
# 결과 (PlacementPlan) = debug_zones[i].placement_trace 로 *기록만*.
# render path / mapper output / final.html 모두 미변경 — B5 baseline SHA 유지.
# B4 frame selection = catalog declaration order (V4 evidence 미사용 — 별 axis).
# Option 1 (PHASE_Z_B4_SOURCE_SHAPE_ENABLED, default OFF) : pilot = F13 top_bullets only.
b4_source_shape_enabled = (
os.environ.get("PHASE_Z_B4_SOURCE_SHAPE_ENABLED", "").strip().lower()
in {"1", "true", "yes"}
)
b1_source_shape = (
contract.get("source_shape")
if b4_source_shape_enabled and contract.get("source_shape") == "top_bullets"
else None
)
content_objects = extract_content_objects(synth_section, source_shape=b1_source_shape)
placement_plan = plan_placement(
content_objects=content_objects,
frame_contracts=list(load_frame_contracts().values()),
section_id=synth_section.section_id,
)
mapper_frame_template_id = unit.frame_template_id
matches_mapper = (
placement_plan.selected_template_id == mapper_frame_template_id
)
match_note: Optional[str] = None
if not matches_mapper:
if placement_plan.selected_template_id is None:
match_note = "no_frame_covers_content_types"
else:
match_note = (
f"B4 selected '{placement_plan.selected_template_id}' but "
f"mapper uses '{mapper_frame_template_id}' (composition V4 rank-1)"
)
placement_trace = {
**asdict(placement_plan),
"mapper_frame_template_id": mapper_frame_template_id,
"frame_selection_matches_mapper": matches_mapper,
"frame_selection_match_note": match_note,
}
# ─── end trace-only runtime 연결 v0 ───
# ─── B4 gatekeeper (Q-V4B4 / PHASE_Z_B4_GATEKEEPER, default OFF) ───
if (
os.environ.get("PHASE_Z_B4_GATEKEEPER", "").strip().lower()
in {"1", "true", "yes"}
and not matches_mapper
):
adapter_record = {
"position": position,
"source_section_ids": unit.source_section_ids,
"merge_type": unit.merge_type,
"template_id": unit.frame_template_id,
"reason": "v4_b4_mismatch",
"mismatch_detail": {
"v4_template_id": mapper_frame_template_id,
"b4_selected_template_id": placement_plan.selected_template_id,
"match_note": match_note,
},
}
adapter_needed_units.append(adapter_record)
print(f" adapter : zone--{position} {unit.source_section_ids}"
f"{unit.frame_template_id} v4_b4_mismatch → adapter_needed (skip render)")
continue
# ─── end B4 gatekeeper ───
# mapper 시도 — 실패 (FitError) 시 zone 을 adapter_needed 로 표시하고 skip
try:
slot_payload = map_mdx_to_slots(synth_section, unit.frame_template_id)
except FitError as e:
adapter_record = {
"position": position,
"source_section_ids": unit.source_section_ids,
"merge_type": unit.merge_type,
"template_id": unit.frame_template_id,
"reason": "fit_error",
"fit_error": str(e),
}
adapter_needed_units.append(adapter_record)
print(f" adapter : zone--{position} {unit.source_section_ids}"
f"{unit.frame_template_id} FitError → adapter_needed (skip render)")
continue
run_dir.mkdir(parents=True, exist_ok=True)
assets_dir = copy_assets(unit.frame_template_id, run_dir)
content_weight = compute_content_weight(synth_section)
truncated_count = slot_payload.get("_truncated_count") # builder 가 truncate 한 경우
zones_data.append({
"position": position,
"template_id": unit.frame_template_id,
"slot_payload": slot_payload,
"content_weight": content_weight,
"min_height_px": min_height_px,
})
debug_zones.append({
"position": position,
"source_section_ids": unit.source_section_ids,
"merge_type": unit.merge_type,
"title": unit.title,
"v4_rank1_frame_id": unit.frame_id,
"v4_rank1_frame_number": unit.frame_number,
"v4_template_id": unit.frame_template_id,
"v4_label": unit.label,
"v4_confidence": unit.confidence,
"phase_z_status": unit.phase_z_status,
"composition_score": unit.score,
"composition_rationale": unit.rationale,
"composition_notes": list(unit.notes), # parent_merged_inferred 정보 신호
"mapper_type": "contract",
"contract_id": unit.frame_template_id,
"contract_frame_id": contract_frame_id,
"builder": builder_name,
"min_height_px": min_height_px,
"slot_payload_keys": sorted(slot_payload.keys()),
"content_truncated_count": truncated_count, # None / N (builder 가 N 개 자름)
"assets_dir": str(assets_dir.relative_to(run_dir)) if assets_dir else None,
"content_weight": content_weight,
# trace-only runtime 연결 v0 — B1 → B2 → B4 chain 결과 (render 미영향).
"placement_trace": placement_trace,
})
# ─── Step 3: Content Object 추출 (B1, trace-only) ───
_write_step_artifact(
run_dir, 3, "content_objects",
data={
"per_zone": [
{
"position": dz["position"],
"section_ids": dz["source_section_ids"],
"internal_regions": (dz.get("placement_trace") or {}).get("internal_regions") or [],
}
for dz in debug_zones
],
},
step_status="trace-only",
pipeline_path_connected=False,
inputs=["step02_normalized.json"],
outputs=["step03_content_objects.json"],
note="현재는 trace 로 기록되지만 render payload 를 직접 만들지는 않음. mapper.py 가 별도로 MDX 직접 파싱.",
)
# ─── Step 4: Section Internal Composition (B2, trace-only) ───
_write_step_artifact(
run_dir, 4, "internal_composition",
data={
"per_zone": [
{
"position": dz["position"],
"section_ids": dz["source_section_ids"],
"selected_template_id": (dz.get("placement_trace") or {}).get("selected_template_id"),
"frame_selection_matches_mapper": (dz.get("placement_trace") or {}).get("frame_selection_matches_mapper"),
"frame_selection_match_note": (dz.get("placement_trace") or {}).get("frame_selection_match_note"),
}
for dz in debug_zones
],
},
step_status="trace-only",
pipeline_path_connected=False,
inputs=["step03_content_objects.json"],
outputs=["step04_internal_composition.json"],
note="현재는 trace 로 기록되지만 render payload 를 직접 만들지는 않음. composition_planner_debug 가 main 결정 (Step 6).",
)
# ─── Step 9: Frame Selection (per zone) ───
_write_step_artifact(
run_dir, 9, "frame_selection",
data={
"per_zone": [
{
"position": dz["position"],
"v4_rank1_frame_number": dz.get("v4_rank1_frame_number"),
"v4_template_id": dz.get("v4_template_id"),
"v4_confidence": dz.get("v4_confidence"),
"v4_label": dz.get("v4_label"),
"phase_z_status": dz.get("phase_z_status"),
"selected_template_id": dz.get("contract_id"),
"mapper_type": dz.get("mapper_type"),
"composition_score": dz.get("composition_score"),
"composition_rationale": dz.get("composition_rationale"),
}
for dz in debug_zones
],
},
step_status="partial",
pipeline_path_connected=True,
inputs=["step05_v4_evidence.json", "step06_composition_plan.json"],
outputs=["step09_frame_selection.json", "step09_frame_matching_candidates.html"],
note="V4 evidence 와 B4 통합 미완 — 별 axis. 현재 = composition planner 의 V4 rank-1 채택.",
)
# Step 9 HTML — V4 top candidates per zone (rank 1~4)
try:
with open(V4_RESULT_PATH, encoding="utf-8") as _vf:
_v4_full = yaml.safe_load(_vf)
_v4_sections = (_v4_full or {}).get("mdx_sections", {}) or {}
except Exception:
_v4_sections = {}
_candidates_html = ""
# path from steps/ 에서 figma_to_html_agent/blocks/{frame_id}/index.html
# steps → phase_z2 → run_id → runs → data → design_agent (5 levels up)
_frame_iframe_base = "../../../../../figma_to_html_agent/blocks"
for dz in debug_zones:
_section_ids = dz.get("source_section_ids", [])
_candidates_html += f"<h2>zone--{dz['position']} (sections: {', '.join(_section_ids)})</h2>"
for _sid in _section_ids:
_sec = _v4_sections.get(_sid)
if not _sec:
_candidates_html += f"<p>section <code>{_sid}</code>: V4 entry 없음</p>"
continue
_candidates_html += (
f"<h3>section <code>{_sid}</code>: {_sec.get('mdx_title', '?')}</h3>"
f"<p>answer_frame_number: <strong>{_sec.get('answer_frame_number', '-')}</strong>"
f" | is_holdout: {_sec.get('is_holdout', '-')}</p>"
f'<div style="display:grid;grid-template-columns:repeat(2,1fr);gap:16px;margin-bottom:24px;">'
)
for _j in (_sec.get("judgments_full32") or [])[:4]:
_rank = _j.get("v4_full_rank")
_frame_id = _j.get("frame_id")
_frame_index = ASSETS_SOURCE_BASE / str(_frame_id) / "index.html"
_has_preview = _frame_index.exists()
_css = "candidate-card candidate-rank1" if _rank == 1 else "candidate-card"
_preview_html = (
f'<div style="margin-top:8px;width:320px;height:180px;overflow:hidden;'
f'border:1px solid #cbd5e1;background:#fff;">'
f'<iframe src="{_frame_iframe_base}/{_frame_id}/index.html" '
f'style="width:1280px;height:720px;border:none;'
f'transform:scale(0.25);transform-origin:0 0;"></iframe>'
f'</div>'
if _has_preview else
f'<div style="margin-top:8px;width:320px;height:180px;'
f'border:1px dashed #cbd5e1;background:#f8fafc;display:flex;'
f'align-items:center;justify-content:center;color:#94a3b8;font-size:13px;">'
f'preview missing<br>'
f'(figma_to_html_agent/blocks/{_frame_id}/index.html 없음)'
f'</div>'
)
_candidates_html += (
f'<div class="{_css}">'
f'<strong>rank {_rank}: frame {_j.get("frame_number")} '
f'(<code>{_j.get("template_id")}</code>)</strong><br>'
f'confidence: {_j.get("confidence", 0):.4f} | '
f'label: <strong>{_j.get("label", "-")}</strong> | '
f'base: {_j.get("base", 0):.4f} | penalty: {_j.get("penalty", 0)}'
f'{_preview_html}'
f"</div>"
)
_candidates_html += "</div>"
_write_step_html(
run_dir, 9, "frame_matching_candidates",
title="V4 Frame Matching Candidates (top 4 per section)",
body_html=(
f"<p>각 zone 의 source section 마다 V4 rank 1~4 후보 표시. "
f"녹색 강조 = rank-1 (현재 선택된 frame).</p>{_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'<div style="background:{_color};border:3px solid {_border};'
f'display:flex;flex-direction:column;justify-content:center;align-items:center;'
f'padding:16px;text-align:center;">'
f'<div style="font-size:18px;font-weight:700;margin-bottom:6px;">zone--{dz["position"]}</div>'
f'<div style="font-size:14px;"><code>{dz.get("v4_template_id") or "-"}</code></div>'
f'<div style="font-size:13px;color:#475569;margin-top:4px;">'
f'height: {dz.get("height_px") or "?"}px / ratio: {dz.get("ratio") or "?"}'
f'</div></div>'
)
_slide_visual_7 = (
f'<div style="width:{_slide_w}px;border:2px solid #1e293b;background:#fff;margin:16px 0;'
f'box-shadow:0 4px 16px rgba(0,0,0,0.1);">'
f'<div style="padding:14px 40px;border-bottom:1px solid #cbd5e1;background:#f8fafc;">'
f'<div style="font-size:15px;color:#64748b;">[slide-title placeholder]</div></div>'
f'<div style="padding:0 40px;height:{_body_h}px;display:grid;'
f'grid-template-rows:{_grid_template};grid-template-columns:{layout_css.get("cols") or "1fr"};'
f'gap:{GRID_GAP}px;box-sizing:border-box;padding-top:12px;padding-bottom:12px;">'
f'{_zone_visuals_7}</div>'
f'<div style="padding:10px 40px;border-top:1px solid #cbd5e1;background:#f8fafc;text-align:right;">'
f'<span style="font-size:13px;color:#64748b;">[slide-footer]</span></div>'
f'</div>'
)
# Step 7-conn — layout 후보 list 시각 (default = candidates[0]).
_candidates_rows = ""
for i, lc in enumerate(layout_candidates_list):
_is_default = (i == 0)
_is_selected = (lc == layout_preset)
_badge = (
'<span style="background:#dcfce7;color:#166534;padding:2px 8px;border-radius:3px;'
'font-size:11px;font-weight:600;margin-left:6px;">selected</span>'
if _is_selected else ''
)
_default_mark = (
'<span style="background:#e0e7ff;color:#3730a3;padding:2px 8px;border-radius:3px;'
'font-size:11px;font-weight:600;margin-left:6px;">default</span>'
if _is_default else
'<span style="background:#f1f5f9;color:#64748b;padding:2px 8px;border-radius:3px;'
'font-size:11px;font-weight:600;margin-left:6px;">alternative</span>'
)
_candidates_rows += (
f'<li style="padding:6px 0;"><code>{lc}</code>{_default_mark}{_badge}</li>'
)
_candidates_block = (
f'<h2>Layout Candidates (unit_count={len(units)})</h2>'
f'<p>source: <code>templates/phase_z2/layouts/layouts.yaml</code> + '
f'<code>select_layout_candidates(unit_count)</code> '
f'(Step 7-B). default = first entry. <strong>현재 single-decision logic 은 '
f'default 만 사용</strong> — Step 9 application_plan 진입 시 후보 평가.</p>'
f'<ul>{_candidates_rows}</ul>'
)
_write_step_html(
run_dir, 7, "selected_layout",
title=f"Selected Layout — {layout_preset}",
body_html=(
f"<h2>Layout Preset: <code>{layout_preset}</code></h2>"
f"<p>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 박스 = 실제 비율 그대로.</p>"
f"{_slide_visual_7}"
f"{_candidates_block}"
f"<h2>Layout CSS (raw)</h2>"
f"<pre><code>{json.dumps(layout_css, ensure_ascii=False, indent=2)}</code></pre>"
),
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'<div style="flex:1;border:1px dashed #94a3b8;background:rgba(255,255,255,0.7);'
f'padding:6px;display:flex;align-items:center;justify-content:center;'
f'font-size:12px;color:#475569;">section {k+1} (균등 1/{_card})</div>'
for k in range(_card)
)
_sub_zones_html += (
f'<div style="flex:1;background:rgba(59,130,246,0.08);border:2px solid #3b82f6;'
f'padding:8px;display:flex;flex-direction:column;gap:4px;">'
f'<div style="font-weight:700;font-size:13px;padding-bottom:6px;border-bottom:1px solid #3b82f6;">'
f'<code>{sz["id"]}</code><br>'
f'<span style="font-size:11px;font-weight:400;color:#475569;">'
f'cardinality.strict={_card} | accepts: {_accepts}'
f'</span>'
f'</div>'
f'{_section_boxes}'
f'</div>'
)
# Step 8-conn — region / display strategy 후보 시각.
_region_pills = " ".join(
f'<span style="background:#dbeafe;color:#1e40af;padding:2px 10px;border-radius:12px;'
f'font-size:11px;font-weight:600;margin-right:4px;">'
f'{rc}{"" if k == 0 else ""}</span>'
for k, rc in enumerate(plan.get("region_layout_candidates") or [])
)
_display_pills = " ".join(
f'<span style="background:#dcfce7;color:#166534;padding:2px 10px;border-radius:12px;'
f'font-size:11px;font-weight:600;margin-right:4px;">'
f'{ds}{"" if k == 0 else ""}</span>'
for k, ds in enumerate(plan.get("display_strategy_candidates") or [])
)
_zone_visuals_8 += (
f'<h3>zone--{plan["position"]} ({_zone_h}px planned, '
f'min_height {plan["min_height_px"]}px, '
f'frame_cardinality_strict {plan["frame_cardinality_strict"]})</h3>'
f'<div style="height:{min(_zone_h, 360)}px;background:{_color};border:3px solid {_border};'
f'display:flex;gap:8px;padding:12px;box-sizing:border-box;margin-bottom:8px;">'
f'{_sub_zones_html}'
f'</div>'
f'<p style="margin-top:0;color:#64748b;font-size:13px;"><em>{plan["child_distribution_note"]}</em></p>'
f'<div style="background:#f8fafc;border-left:3px solid #94a3b8;padding:8px 12px;margin-bottom:16px;font-size:13px;">'
f'<div style="margin-bottom:4px;"><strong>region_layout_candidates</strong> (★ default): {_region_pills}</div>'
f'<div><strong>display_strategy_candidates</strong> (★ default): {_display_pills}</div>'
f'</div>'
)
_write_step_html(
run_dir, 8, "zone_content_placement",
title="Zone + Child Zone Placement Plan (pre-render)",
body_html=(
f"<p><strong>중요:</strong> 이 페이지는 <em>계획값</em> only. 실제 측정값 (clientHeight / scrollHeight / overflow) 은 "
f"<code>step14_visual_check.json</code> 에서 봄.</p>"
f"<p>각 zone 안에 sub_zones 가 columns 로 나열, 각 sub_zone 안 sections 가 cardinality.strict 만큼 균등 row 분배. "
f"F29 process_column 의 첫 section 이 transform_table (AS-IS/TO-BE 표) 인데 균등 분배로 capacity 부족 → 10px overflow 직접 원인.</p>"
f'<div style="background:#fef3c7;border-left:3px solid #f59e0b;padding:8px 12px;margin-bottom:16px;font-size:13px;">'
f'<strong>Step 8-conn placeholder caveat (사용자 lock 2026-05-08):</strong> '
f'region_layout_candidates / display_strategy_candidates 는 현재 '
f'<code>region_count=1 / content_type="text_block"</code> placeholder 신호로만 산출. '
f'Step 3/4 (Content Object 추출 + Internal Composition Planning) 활성화 시 '
f'실제 content_object 신호로 교체 (별 axis).'
f'</div>'
f"{_zone_visuals_8}"
),
step_status="partial",
inputs=["step07_layout.json", "step10_frame_contract.json"],
outputs=["step08_zone_region_ratios.json", "step08_zone_content_placement.html"],
)
# ─── Step 9 v0: Passive Application Plan (사용자 lock 2026-05-08) ───
# status.md §2 schema 따라 per-unit application_plan 생성.
# v0 = passive — Step 6 default 그대로 사용. runtime 결과 무변.
# invariant 5 가지 (status.md §4) 만족 :
# 1. len(application_candidates) == len(v4_candidates) per unit
# 2. application_candidates[i].template_id == v4_candidates[i].template_id (zip 일관)
# 3. current_default_candidate == v4_candidates[0].template_id (application_status="ok")
# 또는 null (application_status="no_v4_candidate")
# 4. len(units) == Step 6 의 plan_composition 결과 (무변)
# 5. application_status == "ok" iff len(v4_candidates) > 0 iff candidate_status == "ok"
application_plan_units = []
for i, unit in enumerate(units):
unit_id = "+".join(unit.source_section_ids)
# zone_region_plans 는 unit i 와 1:1 (Step 6 unit → Step 8 zone_plan).
zone_plan = zone_region_plans[i] if i < len(zone_region_plans) else {}
has_v4 = bool(unit.v4_candidates)
candidate_status = "ok" if has_v4 else "no_non_reject_v4_candidate"
application_status = "ok" if has_v4 else "no_v4_candidate"
current_default = (
unit.v4_candidates[0].template_id if has_v4 else None
)
# Step 7-A axis 보강 — reject 포함 모든 V4 judgments (frontend UI 가
# 모든 frame 의 png 를 카드로 보여주기 위함).
# unit_id = source_section_ids join. parent_merged 는 첫 section 의
# judgments 사용 (parent V4 entry 가 그 section 에 있으므로).
v4_all_for_unit = lookup_v4_all_judgments(v4, unit.source_section_ids[0])
# application_candidates : V4 후보 zip 으로 application_mode 변환
app_candidates = []
for c in unit.v4_candidates:
mode, auto_app, delegated = APPLICATION_MODE_BY_V4_LABEL.get(
c.label, ("exclude", False, None)
)
app_candidates.append({
"template_id": c.template_id,
"frame_id": c.frame_id,
"v4_label": c.label,
"application_mode": mode,
"auto_applicable": auto_app,
"required_changes": [], # v0 = trace-only
"delegated_to": delegated,
})
application_plan_units.append({
"unit_id": unit_id,
"layout_preset": layout_preset,
"layout_candidates": layout_candidates_list,
"region_layout_candidates": zone_plan.get("region_layout_candidates", []),
"display_strategy_candidates": zone_plan.get("display_strategy_candidates", []),
"candidate_status": candidate_status,
"application_status": application_status,
"current_default_candidate": current_default,
"v4_candidates": [
{
"template_id": c.template_id,
"frame_id": c.frame_id,
"frame_number": c.frame_number,
"confidence": c.confidence,
"label": c.label,
}
for c in unit.v4_candidates
],
# Step 7-A axis 보강 (사용자 lock 2026-05-08) — frontend UI 가 reject
# 포함 모든 V4 후보를 시각 차별 (회색) 로 보여줄 수 있도록 trace.
# length = 0~32. label 별 count : v4_candidates 는 non-reject only,
# v4_all_judgments 는 reject 포함.
# catalog_registered = frame_contracts.yaml 에 contract 있는지 여부.
# false 면 사용자가 override 시도해도 Step 7-A 가 skip (render path 미연결).
"v4_all_judgments": [
{
"template_id": c.template_id,
"frame_id": c.frame_id,
"frame_number": c.frame_number,
"confidence": c.confidence,
"label": c.label,
"catalog_registered": get_contract(c.template_id) is not None,
}
for c in v4_all_for_unit
],
"application_candidates": app_candidates,
})
units_with_no_v4 = [
u["unit_id"] for u in application_plan_units
if u["application_status"] == "no_v4_candidate"
]
_write_step_artifact(
run_dir, 9, "application_plan",
data={
"units": application_plan_units,
"candidate_status_summary": {
"units_with_no_v4_candidate": units_with_no_v4,
},
# Step 7-A axis : user override trace
"frame_overrides_applied": frame_overrides_applied,
"frame_overrides_skipped": frame_overrides_skipped,
"v0_lock_note": (
"Step 9 v0 passive (사용자 lock 2026-05-08). "
"Step 6 default 그대로 사용 — runtime 결과 byte-동일. "
"auto decision / scoring 은 Step 9 v1 (별 axis)."
),
},
step_status="partial",
pipeline_path_connected=True,
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"],
note=(
"Step 9 v0 passive application_plan trace (사용자 lock 2026-05-08). "
"V4 후보 + layout / region / display 후보 + V4 label → application_mode "
"변환을 side-by-side 로 기록. v0 invariant 5 가지 (status.md §4) 만족. "
"Step 6 의 default 결정 그대로 (current_default_candidate). "
"auto decision 은 Step 9 v1 (별 axis). region/display 후보는 Step 8-conn "
"의 placeholder signal 종속 (Step 3/4 부재). "
"Step 7-A axis (2026-05-08): frame_overrides_applied 가 사용자 LayoutPanel/"
"FramePanel 선택값을 강제 적용한 trace."
),
)
# Step 9 v0 HTML — per-unit table + application_mode pill.
_mode_color = {
"direct_insert": ("#dcfce7", "#166534"), # green
"same_frame_with_adjustment": ("#dbeafe", "#1e40af"), # blue
"layout_or_region_change": ("#fef3c7", "#92400e"), # yellow (manual)
"exclude": ("#fee2e2", "#991b1b"), # red
}
_unit_blocks_9 = ""
for u in application_plan_units:
_status_badge = (
f'<span style="background:#dcfce7;color:#166534;padding:2px 8px;border-radius:3px;'
f'font-size:11px;font-weight:600;margin-left:6px;">application_status: ok</span>'
if u["application_status"] == "ok" else
f'<span style="background:#fee2e2;color:#991b1b;padding:2px 8px;border-radius:3px;'
f'font-size:11px;font-weight:600;margin-left:6px;">application_status: no_v4_candidate</span>'
)
_default_html = (
f'<code>{u["current_default_candidate"]}</code>'
if u["current_default_candidate"] else '<em>null</em>'
)
_layout_pills = " ".join(
f'<span style="background:#e0e7ff;color:#3730a3;padding:1px 8px;border-radius:10px;'
f'font-size:11px;margin-right:3px;">{lc}{"" if k == 0 else ""}</span>'
for k, lc in enumerate(u["layout_candidates"])
)
_region_pills = " ".join(
f'<span style="background:#dbeafe;color:#1e40af;padding:1px 8px;border-radius:10px;'
f'font-size:11px;margin-right:3px;">{rc}{"" if k == 0 else ""}</span>'
for k, rc in enumerate(u["region_layout_candidates"])
)
_display_pills = " ".join(
f'<span style="background:#dcfce7;color:#166534;padding:1px 8px;border-radius:10px;'
f'font-size:11px;margin-right:3px;">{ds}{"" if k == 0 else ""}</span>'
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 = (
' <span style="background:#fef3c7;color:#92400e;padding:1px 6px;border-radius:3px;'
'font-size:10px;font-weight:600;">current_default</span>'
if _is_default else ''
)
_app_rows += (
f'<tr>'
f'<td style="padding:6px 10px;border-bottom:1px solid #e2e8f0;">'
f'<code>{ac["template_id"]}</code>{_default_mark}</td>'
f'<td style="padding:6px 10px;border-bottom:1px solid #e2e8f0;font-size:12px;">'
f'{ac["v4_label"]}</td>'
f'<td style="padding:6px 10px;border-bottom:1px solid #e2e8f0;">'
f'<span style="background:{_bg};color:{_fg};padding:2px 8px;border-radius:3px;'
f'font-size:11px;font-weight:600;">{ac["application_mode"]}</span></td>'
f'<td style="padding:6px 10px;border-bottom:1px solid #e2e8f0;font-size:12px;">'
f'{"" if ac["auto_applicable"] else ""}</td>'
f'<td style="padding:6px 10px;border-bottom:1px solid #e2e8f0;font-size:11px;color:#64748b;">'
f'{ac["delegated_to"] or ""}</td>'
f'</tr>'
)
_unit_blocks_9 += (
f'<div style="background:#fff;border:1px solid #cbd5e1;border-radius:6px;padding:16px;margin-bottom:20px;">'
f'<h3 style="margin-top:0;">unit: <code>{u["unit_id"]}</code> {_status_badge}</h3>'
f'<p style="margin:4px 0;font-size:13px;color:#475569;">'
f'<strong>layout_preset (default):</strong> <code>{u["layout_preset"]}</code> | '
f'<strong>current_default_candidate:</strong> {_default_html}</p>'
f'<p style="margin:4px 0;font-size:13px;"><strong>layout_candidates (★ default):</strong> {_layout_pills}</p>'
f'<p style="margin:4px 0;font-size:13px;"><strong>region_layout_candidates (★ default, placeholder):</strong> {_region_pills}</p>'
f'<p style="margin:4px 0;font-size:13px;"><strong>display_strategy_candidates (★ default, placeholder):</strong> {_display_pills}</p>'
f'<table style="width:100%;border-collapse:collapse;margin-top:10px;font-size:13px;">'
f'<thead><tr style="background:#f8fafc;">'
f'<th style="padding:6px 10px;text-align:left;border-bottom:2px solid #cbd5e1;">template_id</th>'
f'<th style="padding:6px 10px;text-align:left;border-bottom:2px solid #cbd5e1;">v4_label</th>'
f'<th style="padding:6px 10px;text-align:left;border-bottom:2px solid #cbd5e1;">application_mode</th>'
f'<th style="padding:6px 10px;text-align:left;border-bottom:2px solid #cbd5e1;">auto_app.</th>'
f'<th style="padding:6px 10px;text-align:left;border-bottom:2px solid #cbd5e1;">delegated_to</th>'
f'</tr></thead>'
f'<tbody>{_app_rows}</tbody>'
f'</table>'
f'</div>'
)
_summary_block_9 = (
f'<div style="background:#fef3c7;border-left:3px solid #f59e0b;padding:8px 12px;margin-bottom:16px;font-size:13px;">'
f'<strong>Step 9 v0 lock (사용자 2026-05-08):</strong> '
f'passive trace only — Step 6 default 그대로 사용 (runtime 결과 byte-동일). '
f'auto decision / scoring 은 Step 9 v1 (별 axis). '
f'region / display 후보는 Step 8-conn placeholder signal (Step 3/4 부재) 종속.'
f'</div>'
f'<p><strong>units:</strong> {len(application_plan_units)} | '
f'<strong>units_with_no_v4_candidate:</strong> '
f'{len(units_with_no_v4)} ({", ".join(units_with_no_v4) if units_with_no_v4 else "none"})</p>'
)
_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 = (
"<ul>" + "".join(f"<li>{v}</li>" for v in _vfs) + "</ul>"
if _vfs else "<p>없음</p>"
)
_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'<h2>Overall: <span class="{_ov_class}">{_overall}</span></h2>'
f'<table>'
f'<tr><th>rendered</th><td>{slide_status.get("rendered")}</td></tr>'
f'<tr><th>visual_check_passed</th><td>{slide_status.get("visual_check_passed")}</td></tr>'
f'<tr><th>full_mdx_coverage</th><td>{slide_status.get("full_mdx_coverage")}</td></tr>'
f'<tr><th>aligned_section_ids</th><td>{_aligned}</td></tr>'
f'<tr><th>covered_section_ids</th><td>{_covered}</td></tr>'
f'<tr><th>filtered_section_ids</th><td>{_filtered}</td></tr>'
f'<tr><th>adapter_needed_count</th><td>{slide_status.get("adapter_needed_count", 0)}</td></tr>'
f'<tr><th>content_truncated_count</th><td>{slide_status.get("content_truncated_count", 0)}</td></tr>'
f'</table>'
f'<h2>Visual Fail Reasons</h2>{_vfs_html}'
f'<h2>Note</h2><p>{slide_status.get("note", "")}</p>'
),
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,
)