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}{o}section {_sid}: V4 entry 없음
{_sid}: {_sec.get('mdx_title', '?')}answer_frame_number: {_sec.get('answer_frame_number', '-')}" + f" | is_holdout: {_sec.get('is_holdout', '-')}
" + f'{_j.get("template_id")})각 zone 의 source section 마다 V4 rank 1~4 후보 표시. " + f"녹색 강조 = rank-1 (현재 선택된 frame).
{_candidates_html}" + ), + step_status="done", + inputs=["step05_v4_evidence.json", "tests/matching/v4_full32_result.yaml"], + outputs=["step09_frame_selection.json", "step09_frame_matching_candidates.html"], + ) + + # ─── Step 10: Frame Contract 확인 ─── + contracts_per_zone = [] + for dz in debug_zones: + c = get_contract(dz.get("contract_id")) + contracts_per_zone.append({ + "position": dz["position"], + "contract_id": dz.get("contract_id"), + "frame_id": (c or {}).get("frame_id"), + "family": (c or {}).get("family"), + "source_shape": (c or {}).get("source_shape"), + "cardinality": (c or {}).get("cardinality"), + "visual_hints": (c or {}).get("visual_hints"), + "accepted_content_types": (c or {}).get("accepted_content_types"), + "sub_zones": (c or {}).get("sub_zones"), + "payload_builder": ((c or {}).get("payload") or {}).get("builder"), + "payload_builder_options": ((c or {}).get("payload") or {}).get("builder_options"), + }) + _write_step_artifact( + run_dir, 10, "frame_contract", + data={"per_zone": contracts_per_zone}, + step_status="partial", + pipeline_path_connected=True, + inputs=["step09_frame_selection.json", "templates/phase_z2/catalog/frame_contracts.yaml"], + outputs=["step10_frame_contract.json"], + note="full contract dict (sub_zones / cardinality / visual_hints 포함). density envelope 미선언 (별 axis).", + ) + + # ─── Step 11: Slot Mapping (B4 placement, trace-only) ─── + _write_step_artifact( + run_dir, 11, "slot_mapping", + data={ + "per_zone": [ + { + "position": dz["position"], + "selected_template_id": (dz.get("placement_trace") or {}).get("selected_template_id"), + "slot_assignments": (dz.get("placement_trace") or {}).get("slot_assignments") or [], + "rejection": (dz.get("placement_trace") or {}).get("rejection") or [], + "overflow_buffer": (dz.get("placement_trace") or {}).get("overflow_buffer") or [], + } + for dz in debug_zones + ], + }, + step_status="trace-only", + pipeline_path_connected=False, + inputs=["step04_internal_composition.json", "step10_frame_contract.json"], + outputs=["step11_slot_mapping.json"], + note="B4 PlacementPlan slot_assignments — render path 미연결. 실제 render slot 매핑은 mapper.py 의 builder.", + ) + + # ─── Step 12: Slot Payload (actual values, mapper.py 결과) ─── + _write_step_artifact( + run_dir, 12, "slot_payload", + data={ + "per_zone": [ + { + "position": zd["position"], + "template_id": zd["template_id"], + "builder": (get_contract(zd["template_id"]) or {}).get("payload", {}).get("builder"), + "slot_payload": zd["slot_payload"], + "content_weight": zd.get("content_weight"), + "min_height_px": zd.get("min_height_px"), + } + for zd in zones_data + ], + }, + step_status="done", + pipeline_path_connected=True, + inputs=["step10_frame_contract.json", "step02_normalized.json"], + outputs=["step12_slot_payload.json"], + note="map_with_contract 결과 — actual slot_payload 값 그대로 (key 만 X).", + ) + # 6. Build layout CSS — horizontal-2 = dynamic heights (regression preserve), 그 외 = fr default 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'{dz.get("v4_template_id") or "-"}{lc}{_default_mark}{_badge}source: templates/phase_z2/layouts/layouts.yaml + '
+ f'select_layout_candidates(unit_count) '
+ f'(Step 7-B). default = first entry. 현재 single-decision logic 은 '
+ f'default 만 사용 — Step 9 application_plan 진입 시 후보 평가.
{layout_preset}slide canvas {_slide_w}×{_slide_h}px / slide-body {SLIDE_BODY_WIDTH}×{_body_h}px (left {SLIDE_BODY_LEFT} / top {SLIDE_BODY_TOP}). " + f"안 zone 박스 = 실제 비율 그대로.
" + f"{_slide_visual_7}" + f"{_candidates_block}" + f"{json.dumps(layout_css, ensure_ascii=False, indent=2)}"
+ ),
+ step_status="partial",
+ inputs=["step06_composition_plan.json"],
+ outputs=["step07_layout.json", "step07_selected_layout.html"],
+ )
+
+ # ─── Step 8: Zone + Region Ratio (계획값 only — render 전) ───
+ # Step 8-conn axis (사용자 lock 2026-05-08) — region/display 후보 list per zone (passive 기록).
+ # placeholder signals (Step 3/4 부재 종속) :
+ # region_count = 1 (text-only assumption)
+ # content_type = "text_block"
+ # flow_type / role_pattern / has_visual_element / details_presence /
+ # large_table / long_text / fits_in_region = None or False
+ # Step 3/4 (Content Object 추출 + Internal Composition Planning) 활성화 시
+ # 이 placeholder 가 실제 content_object 신호로 교체됨 (별 axis).
+ _step8_placeholder_signals = {
+ "region_count": 1,
+ "content_type": "text_block",
+ "flow_type": None,
+ "role_pattern": None,
+ "ratio_asymmetric": False,
+ "has_visual_element": False,
+ "details_presence": False,
+ "large_table": False,
+ "long_text": False,
+ "fits_in_region": None,
+ "_note": (
+ "Step 3/4 부재 종속 placeholder — 모든 zone 을 text-only / region_count=1 "
+ "로 가정. 실제 content_object 신호 활성화는 별 axis."
+ ),
+ }
+ zone_region_plans = []
+ for dz in debug_zones:
+ contract = get_contract(dz.get("contract_id")) or {}
+ sub_zones = contract.get("sub_zones") or []
+ cardinality = contract.get("cardinality") or {}
+ visual_hints = contract.get("visual_hints") or {}
+ # Step 8-conn — region / display strategy 후보 (placeholder signals).
+ _region_cands = select_region_layout_candidates(
+ region_count=_step8_placeholder_signals["region_count"],
+ flow_type=_step8_placeholder_signals["flow_type"],
+ role_pattern=_step8_placeholder_signals["role_pattern"],
+ ratio_asymmetric=_step8_placeholder_signals["ratio_asymmetric"],
+ has_visual_element=_step8_placeholder_signals["has_visual_element"],
+ details_presence=_step8_placeholder_signals["details_presence"],
+ large_table=_step8_placeholder_signals["large_table"],
+ long_text=_step8_placeholder_signals["long_text"],
+ )
+ _display_cands = select_display_strategy_candidates(
+ content_type=_step8_placeholder_signals["content_type"],
+ long_text=_step8_placeholder_signals["long_text"],
+ large_table=_step8_placeholder_signals["large_table"],
+ fits_in_region=_step8_placeholder_signals["fits_in_region"],
+ )
+ zone_region_plans.append({
+ "position": dz["position"],
+ "zone_height_px_planned": dz.get("height_px"),
+ "zone_ratio_planned": dz.get("ratio"),
+ "min_height_px": visual_hints.get("min_height_px"),
+ "frame_cardinality_strict": cardinality.get("strict"),
+ "sub_zones_planned": [
+ {
+ "id": sz.get("id"),
+ "role": sz.get("role"),
+ "accepts": sz.get("accepts"),
+ "cardinality_strict": (sz.get("cardinality") or {}).get("strict"),
+ "partial_target_path": sz.get("partial_target_path"),
+ }
+ for sz in sub_zones
+ ],
+ "child_distribution_note": (
+ "현재 child zone (sub_zones 안 sections) 균등 분배 — "
+ "Step 8 region-level ratio ⚠ partial."
+ ),
+ # Step 8-conn — region/display 후보 (placeholder signals)
+ "region_layout_candidates": _region_cands,
+ "display_strategy_candidates": _display_cands,
+ })
+ _write_step_artifact(
+ run_dir, 8, "zone_region_ratios",
+ data={
+ "zone_heights_px_planned": layout_css.get("heights_px"),
+ "zone_ratios_planned": layout_css.get("ratios"),
+ "per_zone_plan": zone_region_plans,
+ # Step 8-conn placeholder signals (사람이 한 곳에서 caveat 확인)
+ "step8_conn_placeholder_signals": _step8_placeholder_signals,
+ },
+ step_status="partial",
+ pipeline_path_connected=True,
+ inputs=["step07_layout.json", "step10_frame_contract.json"],
+ outputs=["step08_zone_region_ratios.json", "step08_zone_content_placement.html"],
+ note=(
+ "계획값 only. 실측값은 Step 14 (visual_check). "
+ "zone-level 만 dynamic, region-level (sub_zone 안 sections) 은 균등 분배 (1/1/1). "
+ "F29 처럼 비균등 콘텐츠 (transform_table + plain) 는 첫 cell 부족 가능 — Step 8 ⚠ partial. "
+ "Step 8-conn (사용자 lock 2026-05-08): per_zone_plan[i] 에 region_layout_candidates + "
+ "display_strategy_candidates 추가 (passive 기록). placeholder signals = text-only / "
+ "region_count=1 (Step 3/4 부재 종속). 실제 content_object 신호 활성화는 별 axis. "
+ "Step 9 application_plan input."
+ ),
+ )
+ # Step 8 HTML — zone + sub_zone + sections 시각 박스 (균등 분배 보임)
+ # Step 8-conn — region/display 후보 list 도 zone 별 박스 아래 표시.
+ _zone_visuals_8 = ""
+ for i, plan in enumerate(zone_region_plans):
+ _color = _zone_palette[i % len(_zone_palette)]
+ _border = _zone_borders[i % len(_zone_borders)]
+ _zone_h = plan['zone_height_px_planned'] or 300
+ _sub_zones_html = ""
+ for sz in plan['sub_zones_planned']:
+ _card = sz['cardinality_strict'] or 1
+ _accepts = ', '.join(sz['accepts'] or [])
+ # sub_zone 안에 section 박스 cardinality 만큼 균등 분배
+ _section_boxes = "".join(
+ f'{sz["id"]}{plan["child_distribution_note"]}
' + f'중요: 이 페이지는 계획값 only. 실제 측정값 (clientHeight / scrollHeight / overflow) 은 "
+ f"step14_visual_check.json 에서 봄.
각 zone 안에 sub_zones 가 columns 로 나열, 각 sub_zone 안 sections 가 cardinality.strict 만큼 균등 row 분배. " + f"F29 process_column 의 첫 section 이 transform_table (AS-IS/TO-BE 표) 인데 균등 분배로 capacity 부족 → 10px overflow 직접 원인.
" + f'region_count=1 / content_type="text_block" placeholder 신호로만 산출. '
+ f'Step 3/4 (Content Object 추출 + Internal Composition Planning) 활성화 시 '
+ f'실제 content_object 신호로 교체 (별 axis).'
+ f'{u["current_default_candidate"]}'
+ if u["current_default_candidate"] else 'null'
+ )
+ _layout_pills = " ".join(
+ f'{lc}{" ★" if k == 0 else ""}'
+ for k, lc in enumerate(u["layout_candidates"])
+ )
+ _region_pills = " ".join(
+ f'{rc}{" ★" if k == 0 else ""}'
+ for k, rc in enumerate(u["region_layout_candidates"])
+ )
+ _display_pills = " ".join(
+ f'{ds}{" ★" if k == 0 else ""}'
+ for k, ds in enumerate(u["display_strategy_candidates"])
+ )
+ _app_rows = ""
+ for k, ac in enumerate(u["application_candidates"]):
+ _bg, _fg = _mode_color.get(ac["application_mode"], ("#f1f5f9", "#475569"))
+ _is_default = (k == 0)
+ _default_mark = (
+ ' current_default'
+ if _is_default else ''
+ )
+ _app_rows += (
+ f'{ac["template_id"]}{_default_mark}{u["unit_id"]} {_status_badge}'
+ f'layout_preset (default): {u["layout_preset"]} | '
+ f'current_default_candidate: {_default_html}
layout_candidates (★ default): {_layout_pills}
' + f'region_layout_candidates (★ default, placeholder): {_region_pills}
' + f'display_strategy_candidates (★ default, placeholder): {_display_pills}
' + f'| template_id | ' + f'v4_label | ' + f'application_mode | ' + f'auto_app. | ' + f'delegated_to | ' + f'
|---|
units: {len(application_plan_units)} | ' + f'units_with_no_v4_candidate: ' + f'{len(units_with_no_v4)} ({", ".join(units_with_no_v4) if units_with_no_v4 else "none"})
' + ) + + _write_step_html( + run_dir, 9, "application_plan", + title="Step 9 v0 — Passive Application Plan", + body_html=( + f'{_summary_block_9}' + f'{_unit_blocks_9}' + ), + step_status="partial", + inputs=[ + "step05_v4_evidence.json", + "step06_composition_plan.json", + "step07_layout.json", + "step08_zone_region_ratios.json", + ], + outputs=["step09_application_plan.json", "step09_application_plan.html"], + ) + # 7. Render single slide html = render_slide(slide_title, slide_footer, zones_data, layout_preset, layout_css) @@ -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 = ( + "없음
" + ) + _aligned = slide_status.get("aligned_section_ids") or [] + _covered = slide_status.get("covered_section_ids") or [] + _filtered = slide_status.get("filtered_section_ids") or [] + _write_step_html( + run_dir, 20, "final_status", + title="Final Slide Status", + body_html=( + f'| rendered | {slide_status.get("rendered")} |
|---|---|
| visual_check_passed | {slide_status.get("visual_check_passed")} |
| full_mdx_coverage | {slide_status.get("full_mdx_coverage")} |
| aligned_section_ids | {_aligned} |
| covered_section_ids | {_covered} |
| filtered_section_ids | {_filtered} |
| adapter_needed_count | {slide_status.get("adapter_needed_count", 0)} |
| content_truncated_count | {slide_status.get("content_truncated_count", 0)} |
{slide_status.get("note", "")}
' + ), + step_status="done", + inputs=["step14_visual_check.json", "step17_retry_trace.json"], + outputs=["step20_slide_status.json", "step20_final_status.html"], + ) + # 13. Debug.json debug_path = write_debug_json( run_dir, layout_preset, debug_zones, layout_css, overflow, @@ -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}")