From ec83405770dc73d0dc785cdc45388ddf2241bf80 Mon Sep 17 00:00:00 2001 From: kyeongmin Date: Fri, 8 May 2026 09:47:11 +0900 Subject: [PATCH] =?UTF-8?q?phase=20z=20pipeline:=20Step=205=20/=206-A=20/?= =?UTF-8?q?=207-conn=20/=208-conn=20/=20Step=209=20v0=20axis=20=EB=B0=95?= =?UTF-8?q?=ED=9E=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사용자 lock 2026-05-08 — Step 5/6/9 boundary reframe. V4 가 frame 선택, Step 6 은 V4 rank-1 default 전사, Step 9 는 application_plan 번역. compat 매트릭스 안 폐기. src/phase_z2_pipeline.py 변경 : - lookup_v4_candidates(v4, section_id, max_n=6) 추가 — V4 non-reject max-6 후보 list. raw 32 entry 는 v4_full32_result.yaml 영속, step05 = 정제 list. lookup_v4_match() (rank-1) 유지 — Step 6 backward compat. (Step 5 보완) - step05_v4_evidence.json schema 확장 — evidence_per_section[i] = {section_id, v4_candidates: [...], candidate_status: "ok" | "no_non_reject_v4_candidate"}. (Step 5 보완) - candidates_lookup_fn 정의 + plan_composition() 에 주입. V4 raw dict 는 composition module 안 봄 — fn injection. (Step 6-A) - Step 6 artifact 의 selected_units[i] 에 v4_candidates 필드 추가. (Step 6-A) - step07_layout.json data 에 unit_count + layout_candidates 필드 추가 (Step 7-B 의 select_layout_candidates 결과). step07_selected_layout.html 에 Layout Candidates 섹션 추가 (default / alternative / selected badge). (Step 7-conn) - step08_zone_region_ratios.json 의 per_zone_plan[i] 에 region_layout_candidates + display_strategy_candidates 필드 추가 (Step 8-B-1/2 후보 함수 호출). step8_conn_placeholder_signals 명시 — Step 3/4 부재 종속 placeholder (region_count=1, content_type="text_block"). step08 HTML 에 candidates pill + placeholder caveat. (Step 8-conn) - APPLICATION_MODE_BY_V4_LABEL 상수 추가 — V4 label → application_mode 변환. - Step 9 v0 artifact block 신설 (~180 줄) — step09_application_plan.json + .html. unit 별 layout_candidates / region_layout_candidates / display_strategy_candidates / v4_candidates / candidate_status / application_status / current_default_candidate / application_candidates 박힘. invariant 5 가지 자연 만족 (status.md §4). (Step 9 v0) src/phase_z2_composition.py 변경 : - CompositionUnit 에 v4_candidates: list field 추가 (additive, logic 무변). duck typed (V4Match-shape) — circular import 회피. (Step 6-A) - collect_candidates() 에 v4_candidates_lookup_fn 인자 추가. 3 분기 (single / parent_merged / parent_merged_inferred) 에서 v4_candidates 채움. (Step 6-A) - plan_composition() 도 v4_candidates_lookup_fn pass-through. (Step 6-A) - stale "pipeline 호출처 X" 주석 정정 (3 곳: select_layout_candidates / select_region_layout_candidates / select_display_strategy_candidates + catalog header 2 곳). Step 7-conn / 8-conn 으로 호출처 박혀 *호출처 X* 사실 위배. (cleanup-1) axis 닫힘 : Step 5 보완 + 6-A + 7-conn + 8-conn + Step 9 v0 + cleanup-1. 폐기 : 6-B (frame ownership transfer — misframed axis. PHASE-Z-CHANGE-LOG.md 2026-05-08 #2 entry 참조). regression 0 : MDX03 fresh run 검증 — final.html / step10/12/13/20 byte-동일. schema 확장만 (step05/06/07/08/09). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/phase_z2_composition.py | 388 +++++++++-- src/phase_z2_pipeline.py | 1230 ++++++++++++++++++++++++++++++++++- 2 files changed, 1545 insertions(+), 73 deletions(-) diff --git a/src/phase_z2_composition.py b/src/phase_z2_composition.py index da83f76..e0e417b 100644 --- a/src/phase_z2_composition.py +++ b/src/phase_z2_composition.py @@ -22,77 +22,296 @@ Pipeline 의 빠진 layer = MDX 덩어리들을 *최종 zone unit* 으로 묶는 from __future__ import annotations from dataclasses import dataclass, field +from pathlib import Path from typing import Optional +import yaml -# ─── 8 Layout Preset Vocabulary ──────────────────────────────── -LAYOUT_PRESETS: dict[str, dict] = { - "single": { - "zones": 1, - "topology": "single", - "positions": ["primary"], - "css_areas": '"primary"', - "css_cols": "1fr", - "css_rows": "1fr", - }, - "horizontal-2": { - "zones": 2, - "topology": "rows", - "positions": ["top", "bottom"], - "css_areas": '"top" "bottom"', - "css_cols": "1fr", - "css_rows": "1fr 1fr", - }, - "vertical-2": { - "zones": 2, - "topology": "cols", - "positions": ["left", "right"], - "css_areas": '"left right"', - "css_cols": "1fr 1fr", - "css_rows": "1fr", - }, - "top-1-bottom-2": { - "zones": 3, - "topology": "T", - "positions": ["top", "bottom-left", "bottom-right"], - "css_areas": '"top top" "bottom-left bottom-right"', - "css_cols": "1fr 1fr", - "css_rows": "1fr 1fr", - }, - "top-2-bottom-1": { - "zones": 3, - "topology": "inverted-T", - "positions": ["top-left", "top-right", "bottom"], - "css_areas": '"top-left top-right" "bottom bottom"', - "css_cols": "1fr 1fr", - "css_rows": "1fr 1fr", - }, - "left-1-right-2": { - "zones": 3, - "topology": "side-T-left", - "positions": ["left", "right-top", "right-bottom"], - "css_areas": '"left right-top" "left right-bottom"', - "css_cols": "1fr 1fr", - "css_rows": "1fr 1fr", - }, - "left-2-right-1": { - "zones": 3, - "topology": "side-T-right", - "positions": ["left-top", "right", "left-bottom"], - "css_areas": '"left-top right" "left-bottom right"', - "css_cols": "1fr 1fr", - "css_rows": "1fr 1fr", - }, - "grid-2x2": { - "zones": 4, - "topology": "2x2", - "positions": ["top-left", "top-right", "bottom-left", "bottom-right"], - "css_areas": '"top-left top-right" "bottom-left bottom-right"', - "css_cols": "1fr 1fr", - "css_rows": "1fr 1fr", - }, -} +# ─── 8 Layout Preset Vocabulary — catalog-loaded (사용자 lock 2026-05-07) ─── +# +# Source of truth = templates/phase_z2/layouts/layouts.yaml (사람이 보고 추가/수정 가능). +# 코드 hardcoded dict 폐기 (Step 7-A catalog 화). logic 변경 X — backward compat. +# +# catalog 의 추가 필드 (render_ready / default_selection / candidate_when) 는 +# 기존 사용처에서 무시됨 — Step 7-B (multiple 후보) / Step 9 (layout × frame +# fit eval) 진입 시 입력. + +_LAYOUTS_CATALOG_PATH = ( + Path(__file__).resolve().parent.parent + / "templates" / "phase_z2" / "layouts" / "layouts.yaml" +) + + +def load_layout_presets() -> dict[str, dict]: + """Load 8 layout presets from catalog. + + backward compat: returns same dict shape as old hardcoded LAYOUT_PRESETS — + keys = layout id (single / horizontal-2 / ...), + each value contains zones / topology / positions / css_areas / css_cols / css_rows. + Additional fields (render_ready / default_selection / candidate_when) + ignored by existing callers, consumed by Step 7-B / Step 9 (별 axis). + """ + with open(_LAYOUTS_CATALOG_PATH, encoding="utf-8") as f: + return yaml.safe_load(f) or {} + + +LAYOUT_PRESETS: dict[str, dict] = load_layout_presets() + + +def select_layout_candidates(unit_count: int) -> list[str]: + """Return layout id candidates matching given unit_count. + + Step 7-B (사용자 lock 2026-05-07) — multiple 후보 generation. + + Args: + unit_count: Final layout placement unit count (Step 4 output). + = section_count + promoted lead_orphans 등. + NOT raw MDX section count — Step 2 raw section count 가 아님. + + Returns: + List of layout ids matching candidate_when.unit_count. + Sort order: + 1. default_selection: true 먼저 (catalog 정의 순서) + 2. default_selection: false 그 다음 (catalog 정의 순서) + Layouts with render_ready: false 는 제외. + + Raises: + ValueError: if unit_count < 1 or > 4 (current catalog scope). + + Note: + 호출처 박힘 (Step 7-conn 2026-05-08) — phase_z2_pipeline.py 의 + step07 artifact 가 본 함수 결과 기록 (passive). 기존 select_layout_preset() + 은 default 결정 그대로. 후보 평가 / auto decision 은 Step 9 v1 (별 axis). + """ + if unit_count < 1 or unit_count > 4: + raise ValueError( + f"unit_count {unit_count} out of catalog scope [1, 4]" + ) + defaults: list[str] = [] + alternatives: list[str] = [] + for layout_id, spec in LAYOUT_PRESETS.items(): + if not spec.get("render_ready", False): + continue + cw = spec.get("candidate_when") or {} + if cw.get("unit_count") != unit_count: + continue + if spec.get("default_selection", False): + defaults.append(layout_id) + else: + alternatives.append(layout_id) + return defaults + alternatives + + +# ─── Region Layout Catalog — Step 8-B-1 (사용자 lock 2026-05-07) ──────── +# +# Source = templates/phase_z2/regions/region_layouts.yaml (SPEC §2.5). +# load 함수 + select_region_layout_candidates(). +# 호출처 박힘 (Step 8-conn 2026-05-08) — phase_z2_pipeline.py 의 step08 artifact 가 +# 본 함수 결과 기록 (placeholder signals: region_count=1, Step 3/4 부재 종속). + +_REGION_LAYOUTS_CATALOG_PATH = ( + Path(__file__).resolve().parent.parent + / "templates" / "phase_z2" / "regions" / "region_layouts.yaml" +) + + +def load_region_layouts() -> dict[str, dict]: + """Load Internal Region layout catalog (SPEC §2.5, 6 entry). + + Returns same dict shape as catalog yaml. + Step 7-A 와 같은 패턴 — source of truth = yaml, code 는 read 만. + """ + with open(_REGION_LAYOUTS_CATALOG_PATH, encoding="utf-8") as f: + return yaml.safe_load(f) or {} + + +REGION_LAYOUTS: dict[str, dict] = load_region_layouts() + + +def select_region_layout_candidates( + region_count: int, + content_type_mix: Optional[list[str]] = None, + details_presence: bool = False, + role_pattern: Optional[str] = None, + ratio_asymmetric: bool = False, + flow_type: Optional[str] = None, + has_visual_element: bool = False, + large_table: bool = False, + long_text: bool = False, +) -> list[str]: + """Return Internal Region layout candidates per SPEC §2.5 decision tree. + + Step 8-B-1 (사용자 lock 2026-05-07) — 후보 generation 함수. + Step 7-B 와 다른 점: SPEC §2.5 는 *순차 결정 트리* (첫 매칭 채택). + Step 7-B 는 단순 매칭 (unit_count 같은 모든 entry). + + Decision rule (sequential, first match wins) — catalog 와 1:1 일치: + 1. region_count == 1 -> region-single + 2. details_presence / large_table / long_text -> region-preview-details + 3. region_count == 4 AND flow_type == 'parallel_4' -> region-grid-2x2 + 4. region_count == 2 AND role_pattern == + 'primary_supporting' AND ratio_asymmetric -> region-main-support + 5. region_count == 2 AND has_visual_element -> region-horizontal-split + 6. fallback (위 미매칭) -> region-vertical-stack + + Sort: + region_count == 1 -> [region-single] (fallback X) + region_count >= 2 -> [매칭, region-vertical-stack] 또는 [region-vertical-stack] + + Raises: + ValueError: region_count < 1 or > 4 (SPEC §2.5 vocabulary scope). + + Note: + 호출처 박힘 (Step 8-conn 2026-05-08) — phase_z2_pipeline.py 의 step08 artifact + 가 본 함수 결과 기록. 현재 placeholder signals (region_count=1, content_type= + "text_block") 종속 — 실제 신호 활성화는 Step 3/4 별 axis. + Step 9 v0 (application_plan) 가 본 후보 list 를 application_candidates 로 해석. + """ + if region_count < 1 or region_count > 4: + raise ValueError( + f"region_count {region_count} out of catalog scope [1, 4]" + ) + + fallback = "region-vertical-stack" + + # 1. region_count == 1 + if region_count == 1: + return ["region-single"] + + # 2. details_presence / large_table / long_text + if details_presence or large_table or long_text: + match = "region-preview-details" + # 3. region_count == 4 + parallel_4 + elif region_count == 4 and flow_type == "parallel_4": + match = "region-grid-2x2" + # 4. region_count == 2 + role_pattern primary_supporting + ratio_asymmetric + elif ( + region_count == 2 + and role_pattern == "primary_supporting" + and ratio_asymmetric + ): + match = "region-main-support" + # 5. region_count == 2 + visual element + elif region_count == 2 and has_visual_element: + match = "region-horizontal-split" + # 6. fallback + else: + return [fallback] + + # 매칭됨 + fallback (단 매칭 == fallback 인 경우 1개만) + if match == fallback: + return [fallback] + return [match, fallback] + + +# ─── Display Strategy Catalog — Step 8-B-2 (사용자 lock 2026-05-07) ──── +# +# Source = templates/phase_z2/regions/display_strategies.yaml (4 entry). +# load 함수 + select_display_strategy_candidates(). +# 호출처 박힘 (Step 8-conn 2026-05-08) — phase_z2_pipeline.py 의 step08 artifact 가 +# 본 함수 결과 기록 (placeholder signals: content_type="text_block", Step 3/4 부재 종속). + +_DISPLAY_STRATEGIES_CATALOG_PATH = ( + Path(__file__).resolve().parent.parent + / "templates" / "phase_z2" / "regions" / "display_strategies.yaml" +) + + +def load_display_strategies() -> dict[str, dict]: + """Load display strategy catalog (4 entry). + + Returns same dict shape as catalog yaml. + Step 7-A / 8-B-1 와 같은 패턴 — source of truth = yaml, code 는 read 만. + """ + with open(_DISPLAY_STRATEGIES_CATALOG_PATH, encoding="utf-8") as f: + return yaml.safe_load(f) or {} + + +DISPLAY_STRATEGIES: dict[str, dict] = load_display_strategies() + + +_KNOWN_CONTENT_TYPES = frozenset({ + "text_block", "table", "image", "details", "decorative_element", +}) + + +def select_display_strategy_candidates( + content_type: str, + long_text: bool = False, + large_table: bool = False, + fits_in_region: Optional[bool] = None, +) -> list[str]: + """Return display strategy candidates per catalog (display_strategies.yaml). + + Step 8-B-2 (사용자 lock 2026-05-07) — 후보 generation 함수. + display_strategies.yaml 만 본다 (region_layouts / frame 은 Step 9 axis). + + Hard filter (catalog 박힌 절대 제약 — applies_to / forbidden_for): + - content_type 이 strategy.applies_to 에 있어야 후보 + - content_type 이 strategy.forbidden_for 에 있으면 자동 제외 + - 핵심 user lock: text_block / table / image / details 는 dropped 절대 X + (catalog forbidden_for 에 박혀 있음 — 원문 무손실 보존) + + Ranking (content_type + fit signal): + decorative_element -> [inline_full, dropped] + image -> [inline_full] + text_block / table / details + long_text / large_table + / fits_in_region == False -> [inline_preview_with_details, + details_only, inline_full] + 그 외 -> [inline_full, + inline_preview_with_details, + details_only] + + Note: + - fits_in_region 은 가벼운 hint 만. 실제 overflow 판단은 Step 9/14/17 axis. + - dropped 는 decorative_element 의 후순위 (공간 부족 신호 전엔 일단 보여주기). + + Raises: + ValueError: content_type 이 catalog scope 밖 + (text_block / table / image / details / decorative_element 외). + + Note: + 호출처 박힘 (Step 8-conn 2026-05-08) — phase_z2_pipeline.py 의 step08 artifact + 가 본 함수 결과 기록. 현재 placeholder signal (content_type="text_block") + 종속 — 실제 신호 활성화는 Step 3/4 별 axis. + Step 9 v0 (application_plan) 가 본 후보 list 를 application_candidates 의 + display_strategy axis 로 해석. + """ + if content_type not in _KNOWN_CONTENT_TYPES: + raise ValueError( + f"content_type {content_type!r} out of catalog scope " + f"(known: {sorted(_KNOWN_CONTENT_TYPES)})" + ) + + # Hard filter — applies_to / forbidden_for (catalog 직독) + eligible = set() + for name, meta in DISPLAY_STRATEGIES.items(): + applies_to = meta.get("applies_to") or [] + forbidden_for = meta.get("forbidden_for") or [] + if content_type in applies_to and content_type not in forbidden_for: + eligible.add(name) + + # Ranking — content_type + fit signal + if content_type == "decorative_element": + order = ["inline_full", "dropped"] + else: + escalate = long_text or large_table or fits_in_region is False + if escalate: + order = [ + "inline_preview_with_details", + "details_only", + "inline_full", + ] + else: + order = [ + "inline_full", + "inline_preview_with_details", + "details_only", + ] + + return [s for s in order if s in eligible] # ─── CompositionUnit ──────────────────────────────────────────── @@ -137,6 +356,14 @@ class CompositionUnit: # 예: "children disagree on rank-1 template_id" / "minority of children non-auto-renderable" notes: list[str] = field(default_factory=list) + # Step 6-A axis 추가 (사용자 lock 2026-05-08). + # V4 후보 list (V4Match-shape duck typed — composition module 은 V4Match dataclass 미import, + # circular dep 회피). 각 entry attrs : template_id / frame_id / frame_number / confidence / label. + # list 순서 = V4 rank (candidates[0] = rank-1 non-reject — 단일 frame_template_id / + # frame_id / label / confidence 와 일치, backward compat lock). + # 0 길이 = "no_non_reject_v4_candidate" 신호 (Step 9 application_plan input). + v4_candidates: list = field(default_factory=list) + # ─── Heading Tree ────────────────────────────────────────────── @@ -192,7 +419,8 @@ def _apply_capacity_fit(candidate: CompositionUnit, capacity_fit_fn) -> None: def collect_candidates(sections, v4_lookup_fn, v4_label_to_status: dict, auto_renderable_statuses: Optional[set[str]] = None, - capacity_fit_fn=None): + capacity_fit_fn=None, + v4_candidates_lookup_fn=None): """Generate composition candidates. v0.1 candidate types : @@ -207,12 +435,17 @@ def collect_candidates(sections, v4_lookup_fn, v4_label_to_status: dict, Args: sections : align 결과 - v4_lookup_fn : (section_id) → V4Match | None + v4_lookup_fn : (section_id) → V4Match | None (rank-1 only, 기존 호환) v4_label_to_status : V4 label → Phase Z status mapping auto_renderable_statuses : 자동 렌더 허용 status set (W1/W3 판정 입력) capacity_fit_fn : Optional (template_id, content) → fit dict. 제공되면 모든 candidate 에 적용 — capacity mismatch 시 auto_selectable=False (silent truncate / mapper FitError 사전 차단). + v4_candidates_lookup_fn : Optional (section_id) → list[V4Match]. + Step 6-A axis (사용자 lock 2026-05-08). non-reject max-N 후보 list. + 제공되면 모든 candidate 에 v4_candidates 필드 채움. + None 이면 v4_candidates = [] (backward compat). + 본 fn 이 V4 raw dict 구조를 흡수 — composition module 은 V4 yaml shape 모름. Returns: list[CompositionUnit] @@ -220,6 +453,10 @@ def collect_candidates(sections, v4_lookup_fn, v4_label_to_status: dict, if auto_renderable_statuses is None: auto_renderable_statuses = set() + def _v4_cands(section_id: str) -> list: + # v4_candidates_lookup_fn 미제공 시 빈 list (backward compat). + return v4_candidates_lookup_fn(section_id) if v4_candidates_lookup_fn else [] + candidates = [] # 1. Separate @@ -238,6 +475,7 @@ def collect_candidates(sections, v4_lookup_fn, v4_label_to_status: dict, phase_z_status=v4_label_to_status.get(match.label, "unknown"), raw_content=s.raw_content, title=s.title, + v4_candidates=_v4_cands(s.section_id), ) _apply_capacity_fit(c, capacity_fit_fn) candidates.append(c) @@ -268,6 +506,7 @@ def collect_candidates(sections, v4_lookup_fn, v4_label_to_status: dict, phase_z_status=v4_label_to_status.get(parent_match.label, "unknown"), raw_content=merged_raw, title=pid, + v4_candidates=_v4_cands(pid), ) _apply_capacity_fit(c_pm, capacity_fit_fn) candidates.append(c_pm) @@ -363,6 +602,8 @@ def collect_candidates(sections, v4_lookup_fn, v4_label_to_status: dict, auto_selectable=auto_selectable, filter_reasons=filter_reasons, notes=notes, + # rep_child 의 V4 후보 list (rep_match 와 같은 출처, frame_* 와 일관). + v4_candidates=_v4_cands(rep_child.section_id), ) _apply_capacity_fit(c_inf, capacity_fit_fn) candidates.append(c_inf) @@ -478,7 +719,8 @@ def select_layout_preset(units: list[CompositionUnit]) -> Optional[str]: def plan_composition(sections, v4_lookup_fn, v4_label_to_status: dict, allowed_statuses: set[str], - capacity_fit_fn=None) -> tuple[list[CompositionUnit], Optional[str], dict]: + capacity_fit_fn=None, + v4_candidates_lookup_fn=None) -> tuple[list[CompositionUnit], Optional[str], dict]: """Composition planner v0.2 entry. v0.2 변경 : @@ -486,6 +728,11 @@ def plan_composition(sections, v4_lookup_fn, v4_label_to_status: dict, (silent truncate / mapper FitError 사전 차단). 불일치 시 auto_selectable=False + filter_reason 'C1: ...'. + Step 6-A axis (사용자 lock 2026-05-08) : + - v4_candidates_lookup_fn 주입 시 모든 CompositionUnit 에 v4_candidates 채움. + logic 변화 X — 단일 frame_template_id / frame_id / label / confidence 는 그대로. + runtime 결과 무변. Step 9 application_plan input 위한 schema 확장. + v0.1 / v0.1.1 동작 (유지) : - parent_merged_inferred candidate 생성 (parent V4 없어도) - review 개념 X. auto_selectable + filter_reasons 만으로 자동 결정 @@ -500,6 +747,7 @@ def plan_composition(sections, v4_lookup_fn, v4_label_to_status: dict, sections, v4_lookup_fn, v4_label_to_status, auto_renderable_statuses=allowed_statuses, capacity_fit_fn=capacity_fit_fn, + v4_candidates_lookup_fn=v4_candidates_lookup_fn, ) scored_all = [score_candidate(c) for c in candidates] diff --git a/src/phase_z2_pipeline.py b/src/phase_z2_pipeline.py index 1e6e2a7..748e1a9 100644 --- a/src/phase_z2_pipeline.py +++ b/src/phase_z2_pipeline.py @@ -42,6 +42,9 @@ 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, @@ -81,6 +84,16 @@ V4_LABEL_TO_PHASE_Z_STATUS = { "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 %} 로 스킵) @@ -89,9 +102,17 @@ MVP1_ALLOWED_STATUSES = {"matched_zone", "adapt_matched_zone"} # 4. slot ↔ MDX item 의미 매핑 → 순서 기반 (간단). V4 anchor_match 정교화는 future # AI 호출 X — MVP-1.5b 의 "MDX 1:1 결정론적 매핑" 룰 그대로. -# Slide-body geometry (legacy contract) -SLIDE_BODY_HEIGHT = 590 -GRID_GAP = 12 +# 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 등) 기준 최소 가독 높이. @@ -249,6 +270,48 @@ def lookup_v4_match(v4: dict, section_id: str) -> Optional[V4Match]: ) +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). @@ -935,6 +998,127 @@ def compute_slide_status(sections: list[MdxSection], } +# ─── Per-step artifact write (locked schema) ──────────────────── +# 모든 step JSON 공통 필드: step_num, step_name, step_status, +# pipeline_path_connected, input, output, note, data +# 사용자 lock — 한 슬라이드 결과물 고치지 말고 시스템 layer 박기 (오답노트 #2) + + +def _write_step_artifact( + run_dir: Path, + step_num: int, + name: str, + data, + *, + step_status: str = "done", + pipeline_path_connected: bool = True, + inputs: Optional[list[str]] = None, + outputs: Optional[list[str]] = None, + note: Optional[str] = None, +) -> Path: + """Write per-step JSON artifact to {run_dir}/steps/step{NN}_{name}.json. + + Locked schema (사용자 직설): + step_num, step_name, step_status, pipeline_path_connected, + input, output, note, data. + Status values: 'done' / 'partial' / 'trace-only' / 'future' / 'failed'. + """ + steps_dir = run_dir / "steps" + steps_dir.mkdir(exist_ok=True) + fname = f"step{step_num:02d}_{name}.json" + fpath = steps_dir / fname + payload = { + "step_num": step_num, + "step_name": name, + "step_status": step_status, + "pipeline_path_connected": pipeline_path_connected, + "input": inputs or [], + "output": outputs or [fname], + "note": note, + "data": data, + } + fpath.write_text( + json.dumps(payload, ensure_ascii=False, indent=2, default=str), + encoding="utf-8", + ) + return fpath + + +def _write_step_html( + run_dir: Path, + step_num: int, + name: str, + title: str, + body_html: str, + *, + step_status: str = "done", + inputs: Optional[list[str]] = None, + outputs: Optional[list[str]] = None, +) -> Path: + """Write per-step HTML artifact with locked header (input/output/status). + + HTML 산출물 = 사용자가 시각으로 판단해야 하는 step (7/8/9/13/20). + """ + steps_dir = run_dir / "steps" + steps_dir.mkdir(exist_ok=True) + fname = f"step{step_num:02d}_{name}.html" + fpath = steps_dir / fname + inputs_lines = "\n".join(f" - {i}" for i in (inputs or [])) + outputs_lines = "\n".join(f" - {o}" for o in (outputs or [fname])) + status_class = ( + "pass" if step_status == "done" + else "fail" if step_status == "failed" + else "partial" + ) + inputs_li = "".join(f"
  • {i}
  • " for i in (inputs or [])) + outputs_li = "".join(f"
  • {o}
  • " for o in (outputs or [fname])) + full_html = f""" + + + +Step {step_num:02d} — {title} + + +

    Step {step_num:02d} — {title}

    +
    + Status: {step_status}
    + Input: +
      {inputs_li}
    + Output: +
      {outputs_li}
    +
    +{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, @@ -984,14 +1168,96 @@ def run_phase_z2_mvp1(mdx_path: Path, run_id: Optional[str] = None) -> 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 —
    팝업 콘텐츠 (감지 미구현) + }, + 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() @@ -999,15 +1265,60 @@ def run_phase_z2_mvp1(mdx_path: Path, run_id: Optional[str] = None) -> Path: 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, ) if not units or layout_preset is None: @@ -1037,6 +1348,55 @@ def run_phase_z2_mvp1(mdx_path: Path, run_id: Optional[str] = None) -> Path: 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"] @@ -1180,6 +1540,215 @@ def run_phase_z2_mvp1(mdx_path: Path, run_id: Optional[str] = None) -> Path: "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"

    zone--{dz['position']} (sections: {', '.join(_section_ids)})

    " + for _sid in _section_ids: + _sec = _v4_sections.get(_sid) + if not _sec: + _candidates_html += f"

    section {_sid}: V4 entry 없음

    " + continue + _candidates_html += ( + f"

    section {_sid}: {_sec.get('mdx_title', '?')}

    " + f"

    answer_frame_number: {_sec.get('answer_frame_number', '-')}" + f" | is_holdout: {_sec.get('is_holdout', '-')}

    " + f'
    ' + ) + 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'
    ' + f'' + f'
    ' + if _has_preview else + f'
    ' + f'preview missing
    ' + f'(figma_to_html_agent/blocks/{_frame_id}/index.html 없음)' + f'
    ' + ) + _candidates_html += ( + f'
    ' + f'rank {_rank}: frame {_j.get("frame_number")} ' + f'({_j.get("template_id")})
    ' + f'confidence: {_j.get("confidence", 0):.4f} | ' + f'label: {_j.get("label", "-")} | ' + f'base: {_j.get("base", 0):.4f} | penalty: {_j.get("penalty", 0)}' + f'{_preview_html}' + f"
    " + ) + _candidates_html += "
    " + _write_step_html( + run_dir, 9, "frame_matching_candidates", + title="V4 Frame Matching Candidates (top 4 per section)", + body_html=( + f"

    각 zone 의 source section 마다 V4 rank 1~4 후보 표시. " + f"녹색 강조 = rank-1 (현재 선택된 frame).

    {_candidates_html}" + ), + step_status="done", + inputs=["step05_v4_evidence.json", "tests/matching/v4_full32_result.yaml"], + outputs=["step09_frame_selection.json", "step09_frame_matching_candidates.html"], + ) + + # ─── Step 10: Frame Contract 확인 ─── + contracts_per_zone = [] + for dz in debug_zones: + c = get_contract(dz.get("contract_id")) + contracts_per_zone.append({ + "position": dz["position"], + "contract_id": dz.get("contract_id"), + "frame_id": (c or {}).get("frame_id"), + "family": (c or {}).get("family"), + "source_shape": (c or {}).get("source_shape"), + "cardinality": (c or {}).get("cardinality"), + "visual_hints": (c or {}).get("visual_hints"), + "accepted_content_types": (c or {}).get("accepted_content_types"), + "sub_zones": (c or {}).get("sub_zones"), + "payload_builder": ((c or {}).get("payload") or {}).get("builder"), + "payload_builder_options": ((c or {}).get("payload") or {}).get("builder_options"), + }) + _write_step_artifact( + run_dir, 10, "frame_contract", + data={"per_zone": contracts_per_zone}, + step_status="partial", + pipeline_path_connected=True, + inputs=["step09_frame_selection.json", "templates/phase_z2/catalog/frame_contracts.yaml"], + outputs=["step10_frame_contract.json"], + note="full contract dict (sub_zones / cardinality / visual_hints 포함). density envelope 미선언 (별 axis).", + ) + + # ─── Step 11: Slot Mapping (B4 placement, trace-only) ─── + _write_step_artifact( + run_dir, 11, "slot_mapping", + data={ + "per_zone": [ + { + "position": dz["position"], + "selected_template_id": (dz.get("placement_trace") or {}).get("selected_template_id"), + "slot_assignments": (dz.get("placement_trace") or {}).get("slot_assignments") or [], + "rejection": (dz.get("placement_trace") or {}).get("rejection") or [], + "overflow_buffer": (dz.get("placement_trace") or {}).get("overflow_buffer") or [], + } + for dz in debug_zones + ], + }, + step_status="trace-only", + pipeline_path_connected=False, + inputs=["step04_internal_composition.json", "step10_frame_contract.json"], + outputs=["step11_slot_mapping.json"], + note="B4 PlacementPlan slot_assignments — render path 미연결. 실제 render slot 매핑은 mapper.py 의 builder.", + ) + + # ─── Step 12: Slot Payload (actual values, mapper.py 결과) ─── + _write_step_artifact( + run_dir, 12, "slot_payload", + data={ + "per_zone": [ + { + "position": zd["position"], + "template_id": zd["template_id"], + "builder": (get_contract(zd["template_id"]) or {}).get("payload", {}).get("builder"), + "slot_payload": zd["slot_payload"], + "content_weight": zd.get("content_weight"), + "min_height_px": zd.get("min_height_px"), + } + for zd in zones_data + ], + }, + step_status="done", + pipeline_path_connected=True, + inputs=["step10_frame_contract.json", "step02_normalized.json"], + outputs=["step12_slot_payload.json"], + note="map_with_contract 결과 — actual slot_payload 값 그대로 (key 만 X).", + ) + # 6. Build layout CSS — horizontal-2 = dynamic heights (regression preserve), 그 외 = fr default layout_css = build_layout_css(layout_preset, zones_data) if layout_css["dynamic_rows"]: @@ -1190,6 +1759,491 @@ def run_phase_z2_mvp1(mdx_path: Path, run_id: Optional[str] = None) -> Path: 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_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'
    ' + f'
    zone--{dz["position"]}
    ' + f'
    {dz.get("v4_template_id") or "-"}
    ' + f'
    ' + f'height: {dz.get("height_px") or "?"}px / ratio: {dz.get("ratio") or "?"}' + f'
    ' + ) + _slide_visual_7 = ( + f'
    ' + f'
    ' + f'
    [slide-title placeholder]
    ' + f'
    ' + f'{_zone_visuals_7}
    ' + f'
    ' + f'[slide-footer]
    ' + f'
    ' + ) + # 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 = ( + 'selected' + if _is_selected else '' + ) + _default_mark = ( + 'default' + if _is_default else + 'alternative' + ) + _candidates_rows += ( + f'
  • {lc}{_default_mark}{_badge}
  • ' + ) + _candidates_block = ( + f'

    Layout Candidates (unit_count={len(units)})

    ' + f'

    source: templates/phase_z2/layouts/layouts.yaml + ' + f'select_layout_candidates(unit_count) ' + f'(Step 7-B). default = first entry. 현재 single-decision logic 은 ' + f'default 만 사용 — Step 9 application_plan 진입 시 후보 평가.

    ' + f'
      {_candidates_rows}
    ' + ) + _write_step_html( + run_dir, 7, "selected_layout", + title=f"Selected Layout — {layout_preset}", + body_html=( + f"

    Layout Preset: {layout_preset}

    " + f"

    slide canvas {_slide_w}×{_slide_h}px / slide-body {SLIDE_BODY_WIDTH}×{_body_h}px (left {SLIDE_BODY_LEFT} / top {SLIDE_BODY_TOP}). " + f"안 zone 박스 = 실제 비율 그대로.

    " + f"{_slide_visual_7}" + f"{_candidates_block}" + f"

    Layout CSS (raw)

    " + f"
    {json.dumps(layout_css, ensure_ascii=False, indent=2)}
    " + ), + step_status="partial", + inputs=["step06_composition_plan.json"], + outputs=["step07_layout.json", "step07_selected_layout.html"], + ) + + # ─── Step 8: Zone + Region Ratio (계획값 only — render 전) ─── + # Step 8-conn axis (사용자 lock 2026-05-08) — region/display 후보 list per zone (passive 기록). + # placeholder signals (Step 3/4 부재 종속) : + # region_count = 1 (text-only assumption) + # content_type = "text_block" + # flow_type / role_pattern / has_visual_element / details_presence / + # large_table / long_text / fits_in_region = None or False + # Step 3/4 (Content Object 추출 + Internal Composition Planning) 활성화 시 + # 이 placeholder 가 실제 content_object 신호로 교체됨 (별 axis). + _step8_placeholder_signals = { + "region_count": 1, + "content_type": "text_block", + "flow_type": None, + "role_pattern": None, + "ratio_asymmetric": False, + "has_visual_element": False, + "details_presence": False, + "large_table": False, + "long_text": False, + "fits_in_region": None, + "_note": ( + "Step 3/4 부재 종속 placeholder — 모든 zone 을 text-only / region_count=1 " + "로 가정. 실제 content_object 신호 활성화는 별 axis." + ), + } + zone_region_plans = [] + for dz in debug_zones: + contract = get_contract(dz.get("contract_id")) or {} + sub_zones = contract.get("sub_zones") or [] + cardinality = contract.get("cardinality") or {} + visual_hints = contract.get("visual_hints") or {} + # Step 8-conn — region / display strategy 후보 (placeholder signals). + _region_cands = select_region_layout_candidates( + region_count=_step8_placeholder_signals["region_count"], + flow_type=_step8_placeholder_signals["flow_type"], + role_pattern=_step8_placeholder_signals["role_pattern"], + ratio_asymmetric=_step8_placeholder_signals["ratio_asymmetric"], + has_visual_element=_step8_placeholder_signals["has_visual_element"], + details_presence=_step8_placeholder_signals["details_presence"], + large_table=_step8_placeholder_signals["large_table"], + long_text=_step8_placeholder_signals["long_text"], + ) + _display_cands = select_display_strategy_candidates( + content_type=_step8_placeholder_signals["content_type"], + long_text=_step8_placeholder_signals["long_text"], + large_table=_step8_placeholder_signals["large_table"], + fits_in_region=_step8_placeholder_signals["fits_in_region"], + ) + zone_region_plans.append({ + "position": dz["position"], + "zone_height_px_planned": dz.get("height_px"), + "zone_ratio_planned": dz.get("ratio"), + "min_height_px": visual_hints.get("min_height_px"), + "frame_cardinality_strict": cardinality.get("strict"), + "sub_zones_planned": [ + { + "id": sz.get("id"), + "role": sz.get("role"), + "accepts": sz.get("accepts"), + "cardinality_strict": (sz.get("cardinality") or {}).get("strict"), + "partial_target_path": sz.get("partial_target_path"), + } + for sz in sub_zones + ], + "child_distribution_note": ( + "현재 child zone (sub_zones 안 sections) 균등 분배 — " + "Step 8 region-level ratio ⚠ partial." + ), + # Step 8-conn — region/display 후보 (placeholder signals) + "region_layout_candidates": _region_cands, + "display_strategy_candidates": _display_cands, + }) + _write_step_artifact( + run_dir, 8, "zone_region_ratios", + data={ + "zone_heights_px_planned": layout_css.get("heights_px"), + "zone_ratios_planned": layout_css.get("ratios"), + "per_zone_plan": zone_region_plans, + # Step 8-conn placeholder signals (사람이 한 곳에서 caveat 확인) + "step8_conn_placeholder_signals": _step8_placeholder_signals, + }, + step_status="partial", + pipeline_path_connected=True, + inputs=["step07_layout.json", "step10_frame_contract.json"], + outputs=["step08_zone_region_ratios.json", "step08_zone_content_placement.html"], + note=( + "계획값 only. 실측값은 Step 14 (visual_check). " + "zone-level 만 dynamic, region-level (sub_zone 안 sections) 은 균등 분배 (1/1/1). " + "F29 처럼 비균등 콘텐츠 (transform_table + plain) 는 첫 cell 부족 가능 — Step 8 ⚠ partial. " + "Step 8-conn (사용자 lock 2026-05-08): per_zone_plan[i] 에 region_layout_candidates + " + "display_strategy_candidates 추가 (passive 기록). placeholder signals = text-only / " + "region_count=1 (Step 3/4 부재 종속). 실제 content_object 신호 활성화는 별 axis. " + "Step 9 application_plan input." + ), + ) + # Step 8 HTML — zone + sub_zone + sections 시각 박스 (균등 분배 보임) + # Step 8-conn — region/display 후보 list 도 zone 별 박스 아래 표시. + _zone_visuals_8 = "" + for i, plan in enumerate(zone_region_plans): + _color = _zone_palette[i % len(_zone_palette)] + _border = _zone_borders[i % len(_zone_borders)] + _zone_h = plan['zone_height_px_planned'] or 300 + _sub_zones_html = "" + for sz in plan['sub_zones_planned']: + _card = sz['cardinality_strict'] or 1 + _accepts = ', '.join(sz['accepts'] or []) + # sub_zone 안에 section 박스 cardinality 만큼 균등 분배 + _section_boxes = "".join( + f'
    section {k+1} (균등 1/{_card})
    ' + for k in range(_card) + ) + _sub_zones_html += ( + f'
    ' + f'
    ' + f'{sz["id"]}
    ' + f'' + f'cardinality.strict={_card} | accepts: {_accepts}' + f'' + f'
    ' + f'{_section_boxes}' + f'
    ' + ) + # Step 8-conn — region / display strategy 후보 시각. + _region_pills = " ".join( + f'' + f'{rc}{" ★" if k == 0 else ""}' + for k, rc in enumerate(plan.get("region_layout_candidates") or []) + ) + _display_pills = " ".join( + f'' + f'{ds}{" ★" if k == 0 else ""}' + for k, ds in enumerate(plan.get("display_strategy_candidates") or []) + ) + _zone_visuals_8 += ( + f'

    zone--{plan["position"]} ({_zone_h}px planned, ' + f'min_height {plan["min_height_px"]}px, ' + f'frame_cardinality_strict {plan["frame_cardinality_strict"]})

    ' + f'
    ' + f'{_sub_zones_html}' + f'
    ' + f'

    {plan["child_distribution_note"]}

    ' + f'
    ' + f'
    region_layout_candidates (★ default): {_region_pills}
    ' + f'
    display_strategy_candidates (★ default): {_display_pills}
    ' + f'
    ' + ) + _write_step_html( + run_dir, 8, "zone_content_placement", + title="Zone + Child Zone Placement Plan (pre-render)", + body_html=( + f"

    중요: 이 페이지는 계획값 only. 실제 측정값 (clientHeight / scrollHeight / overflow) 은 " + f"step14_visual_check.json 에서 봄.

    " + f"

    각 zone 안에 sub_zones 가 columns 로 나열, 각 sub_zone 안 sections 가 cardinality.strict 만큼 균등 row 분배. " + f"F29 process_column 의 첫 section 이 transform_table (AS-IS/TO-BE 표) 인데 균등 분배로 capacity 부족 → 10px overflow 직접 원인.

    " + f'
    ' + f'Step 8-conn placeholder caveat (사용자 lock 2026-05-08): ' + f'region_layout_candidates / display_strategy_candidates 는 현재 ' + f'region_count=1 / content_type="text_block" placeholder 신호로만 산출. ' + f'Step 3/4 (Content Object 추출 + Internal Composition Planning) 활성화 시 ' + f'실제 content_object 신호로 교체 (별 axis).' + f'
    ' + 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 + ) + + # 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 + ], + "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, + }, + "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 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'application_status: ok' + if u["application_status"] == "ok" else + f'application_status: no_v4_candidate' + ) + _default_html = ( + f'{u["current_default_candidate"]}' + if u["current_default_candidate"] else 'null' + ) + _layout_pills = " ".join( + f'{lc}{" ★" if k == 0 else ""}' + for k, lc in enumerate(u["layout_candidates"]) + ) + _region_pills = " ".join( + f'{rc}{" ★" if k == 0 else ""}' + for k, rc in enumerate(u["region_layout_candidates"]) + ) + _display_pills = " ".join( + f'{ds}{" ★" if k == 0 else ""}' + for k, ds in enumerate(u["display_strategy_candidates"]) + ) + _app_rows = "" + for k, ac in enumerate(u["application_candidates"]): + _bg, _fg = _mode_color.get(ac["application_mode"], ("#f1f5f9", "#475569")) + _is_default = (k == 0) + _default_mark = ( + ' current_default' + if _is_default else '' + ) + _app_rows += ( + f'' + f'' + f'{ac["template_id"]}{_default_mark}' + f'' + f'{ac["v4_label"]}' + f'' + f'{ac["application_mode"]}' + f'' + f'{"✓" if ac["auto_applicable"] else "✗"}' + f'' + f'{ac["delegated_to"] or "—"}' + f'' + ) + _unit_blocks_9 += ( + f'
    ' + f'

    unit: {u["unit_id"]} {_status_badge}

    ' + f'

    ' + f'layout_preset (default): {u["layout_preset"]} | ' + f'current_default_candidate: {_default_html}

    ' + f'

    layout_candidates (★ default): {_layout_pills}

    ' + f'

    region_layout_candidates (★ default, placeholder): {_region_pills}

    ' + f'

    display_strategy_candidates (★ default, placeholder): {_display_pills}

    ' + f'' + f'' + f'' + f'' + f'' + f'' + f'' + f'' + f'{_app_rows}' + f'
    template_idv4_labelapplication_modeauto_app.delegated_to
    ' + f'
    ' + ) + + _summary_block_9 = ( + f'
    ' + f'Step 9 v0 lock (사용자 2026-05-08): ' + 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'
    ' + f'

    units: {len(application_plan_units)} | ' + f'units_with_no_v4_candidate: ' + f'{len(units_with_no_v4)} ({", ".join(units_with_no_v4) if units_with_no_v4 else "none"})

    ' + ) + + _write_step_html( + run_dir, 9, "application_plan", + title="Step 9 v0 — Passive Application Plan", + body_html=( + f'{_summary_block_9}' + f'{_unit_blocks_9}' + ), + step_status="partial", + inputs=[ + "step05_v4_evidence.json", + "step06_composition_plan.json", + "step07_layout.json", + "step08_zone_region_ratios.json", + ], + outputs=["step09_application_plan.json", "step09_application_plan.html"], + ) + # 7. Render single slide html = render_slide(slide_title, slide_footer, zones_data, layout_preset, layout_css) @@ -1198,19 +2252,78 @@ def run_phase_z2_mvp1(mdx_path: Path, run_id: Optional[str] = None) -> Path: 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( @@ -1241,6 +2354,43 @@ def run_phase_z2_mvp1(mdx_path: Path, run_id: Optional[str] = None) -> Path: # 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, @@ -1248,6 +2398,50 @@ def run_phase_z2_mvp1(mdx_path: Path, run_id: Optional[str] = None) -> Path: 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 = ( + "
      " + "".join(f"
    • {v}
    • " for v in _vfs) + "
    " + if _vfs else "

    없음

    " + ) + _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'

    Overall: {_overall}

    ' + f'' + f'' + f'' + f'' + f'' + f'' + f'' + f'' + f'' + f'
    rendered{slide_status.get("rendered")}
    visual_check_passed{slide_status.get("visual_check_passed")}
    full_mdx_coverage{slide_status.get("full_mdx_coverage")}
    aligned_section_ids{_aligned}
    covered_section_ids{_covered}
    filtered_section_ids{_filtered}
    adapter_needed_count{slide_status.get("adapter_needed_count", 0)}
    content_truncated_count{slide_status.get("content_truncated_count", 0)}
    ' + f'

    Visual Fail Reasons

    {_vfs_html}' + f'

    Note

    {slide_status.get("note", "")}

    ' + ), + step_status="done", + inputs=["step14_visual_check.json", "step17_retry_trace.json"], + outputs=["step20_slide_status.json", "step20_final_status.html"], + ) + # 13. Debug.json debug_path = write_debug_json( run_dir, layout_preset, debug_zones, layout_css, overflow, @@ -1259,6 +2453,36 @@ def run_phase_z2_mvp1(mdx_path: Path, run_id: Optional[str] = None) -> Path: ) 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}")