2832 lines
128 KiB
Python
2832 lines
128 KiB
Python
"""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,
|
||
)
|