Files
C.E.L_Slide_test2/src/phase_z2_pipeline.py
kyeongmin 8a201337f7 Add Phase Z B4 gatekeeper feature flag
- gate V4/B4 mismatch zones via PHASE_Z_B4_GATEKEEPER env (default OFF)
- record mismatch as adapter_needed_units with reason and mismatch_detail
- preserve render path byte-identical when flag unset
2026-05-06 17:01:47 +09:00

1323 lines
57 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,
)
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"}
# 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-body geometry (legacy contract)
SLIDE_BODY_HEIGHT = 590
GRID_GAP = 12
# 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"],
)
# ─── 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) -> 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.
향후 cardinality_fit / density_score axis 가 score_candidate 에 들어가면
cols/rows 도 dynamic 으로 확장 가능.
"""
preset = LAYOUT_PRESETS[layout_preset]
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 됐지만 일부 콘텐츠 손실)."
),
}
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) -> 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
"""
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"
print(f"[Phase Z-2 MVP-1.5b] start — mdx={mdx_path.name}, run_id={run_id}")
# 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'}")
# 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]})")
# 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)
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,
)
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}")
# 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] = []
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).
content_objects = extract_content_objects(synth_section)
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,
})
# 6. Build layout CSS — horizontal-2 = dynamic heights (regression preserve), 그 외 = fr default
layout_css = build_layout_css(layout_preset, zones_data)
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']})")
# 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}")
# 9. Selenium check
print(" visual : running per-zone overflow check ...")
overflow = run_overflow_check(out_path)
# 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)
# 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)
# 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)
# 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,
)
# 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}")
# 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__":
if len(sys.argv) < 2:
print("Usage: python phase_z2_pipeline.py <mdx_path> [run_id]", file=sys.stderr)
sys.exit(2)
mdx = Path(sys.argv[1])
rid = sys.argv[2] if len(sys.argv) > 2 else None
run_phase_z2_mvp1(mdx, rid)