Files
C.E.L_Slide_test2/src/phase_z2_pipeline.py
kyeongmin 1186ad8ae2 feat(#76): IMP-47B reject-as-AI-adaptation activation (u1~u13 backend + tests)
- u1~u9: AI fallback infrastructure (router/prompts/schema/validator) + Step 12 hook
- u10: e2e reject chain (writes final.html with AI-repaired slot, full coverage)
- u11: frontend wiring deferred to follow-up commit (split from IMP-41 hunks)
- u12: coverage_invariant guard
- u13: cache save gate (visual_check PASS + user_approved/auto_cache) — Codex #22 verified

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 00:19:10 +09:00

5867 lines
273 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Phase Z-2 MVP-1.5b — single slide + Type B + frame-derived adapted blocks.
원래 Phase Z 설계 복귀 (멀티-슬라이드 / native-fit 모두 폐기) :
- MDX 1 = slide 1
- slide-base → slide-body → layout preset (Type B) → zones[] → frame-derived block (zone-compatible adapt)
- frame은 시각 언어 / slot 구성 / 패턴의 source. native geometry 통째 삽입 X.
- AI 는 layout / zone / frame / variant 선택에 관여 X — code / catalog 가 결정.
MVP-1.5b spec :
- 대상 : MDX 03 (회귀)
- 출력 : data/runs/{run_id}/phase_z2/final.html (single slide)
- AI : 미사용 — MDX → slot_payload 결정론적 매핑
- status : matched_zone only — non-matched 발생 시 abort + error.json
- layout : 2 sections → Type B (top + bottom zones)
- Frame partials : templates/phase_z2/families/{template_id}.html (Figma 시각 언어 promote, geometry adapt)
- Assets : render time copy → data/runs/{run_id}/phase_z2/assets/{template_id}/
상세 설계 :
- docs/architecture/PHASE-Z-CATALOG-RUNTIME-DESIGN.md § 17 (frame-derived partial promotion + zone-compatible adapt)
이전 실험 실패 기록 :
- mvp1_test5 : scaffold 임의 — frame 느낌 부재
- mvp1.5_test3 : frame native 통째 — slide 대체
- mvp1.5a_test1 : 멀티-슬라이드 — MDX 1=slide 1 위반
- mvp1.5b_test* : 본 모듈, 원래 설계 라인 합류
"""
import json
import os
import re
import shutil
import sys
import time
from dataclasses import asdict, dataclass, field
from pathlib import Path
from typing import Optional
import yaml
from jinja2 import Environment, FileSystemLoader, select_autoescape
from phase_z2_composition import (
LAYOUT_PRESETS,
CompositionUnit,
derive_parent_id,
plan_composition,
select_display_strategy_candidates,
select_layout_candidates,
select_region_layout_candidates,
)
from phase_z2_mapper import (
FitError,
compute_capacity_fit,
get_contract,
load_frame_contracts,
load_v4_fallback_policy,
map_with_contract,
)
from phase_z2_classifier import classify_visual_runtime_check
from phase_z2_router import route_fit_classification
from phase_z2_retry import (
DEFAULT_SAFETY_MARGIN_PX,
apply_cross_zone_redistribute_css,
apply_font_step_compression_css,
apply_glue_compression_css,
apply_retry_to_layout_css,
plan_cross_zone_redistribute,
plan_font_step_compression,
plan_glue_compression,
plan_zone_ratio_retry,
)
from phase_z2_failure_router import (
enrich_retry_trace_with_failure_classification,
route_retry_failure,
)
# trace-only runtime 연결 v0 — B1 → B4 chain.
# final.html / mapper / render path 미영향. debug_zones[i].placement_trace 만 기록.
from phase_z2_content_extractor import extract_content_objects, extract_rich_content_objects
from phase_z2_placement_planner import plan_placement
# IMP-47B u4 — Step 12 AI repair wiring. gather() short-circuits at the
# router when settings.ai_fallback_enabled is False (default), so import
# at module load is safe for the AI=0 normal path (PZ-1). Activation gate
# stays in src/config.py + src/phase_z2_ai_fallback/router.py.
from src.phase_z2_ai_fallback.step12 import gather_step12_ai_repair_proposals
# ─── Constants ──────────────────────────────────────────────────
PROJECT_ROOT = Path(__file__).parent.parent
TEMPLATE_DIR = PROJECT_ROOT / "templates" / "phase_z2"
ASSETS_SOURCE_BASE = PROJECT_ROOT / "figma_to_html_agent" / "blocks"
V4_RESULT_PATH = PROJECT_ROOT / "tests" / "matching" / "v4_full32_result.yaml"
RUNS_DIR = PROJECT_ROOT / "data" / "runs"
# V4 label → Phase Z status (§ 7.4 매트릭스)
V4_LABEL_TO_PHASE_Z_STATUS = {
"use_as_is": "matched_zone",
"light_edit": "adapt_matched_zone",
"restructure": "extract_matched_zone",
"reject": "fallback_candidate",
}
MVP1_ALLOWED_STATUSES = {"matched_zone", "adapt_matched_zone"}
# Step 9 v0 (사용자 lock 2026-05-08) — V4 label → application_mode 변환.
# tuple = (application_mode, auto_applicable, delegated_to).
# status.md §2 Q3 / Q7 lock 따라.
APPLICATION_MODE_BY_V4_LABEL = {
"use_as_is": ("direct_insert", True, "step10_contract_check"),
"light_edit": ("same_frame_with_adjustment", True, "step10_contract_check"),
"restructure": ("layout_or_region_change", False, "human_review"),
"reject": ("exclude", False, None),
}
# adapt_matched_zone (V4 light_edit) = frame 구조 동일, 텍스트만 minor edit 필요.
# minor edit 정책 (mapper 의무) :
# 1. MDX item 수 < frame slot 수 → 빈 slot 그대로 (Jinja2 {% if %} 로 스킵)
# 2. MDX item 수 > frame slot 수 → 추가 item 누락 (truncate) — debug.json 에 기록
# 3. 텍스트 길이 mismatch → 그대로 통과 (overflow 는 zone-fit + Selenium check 가 처리)
# 4. slot ↔ MDX item 의미 매핑 → 순서 기반 (간단). V4 anchor_match 정교화는 future
# AI 호출 X — MVP-1.5b 의 "MDX 1:1 결정론적 매핑" 룰 그대로.
# Slide canvas / body geometry — front 기준 정상화 (2026-05-07)
# 참조: D:/ad-hoc/kei/design_agent_front/slide-base.html
# slide-base.html CSS 와 1:1 일치해야 함 (불일치 시 layout 계산 어긋남)
SLIDE_W = 1280
SLIDE_H = 720
SLIDE_BODY_LEFT = 50
SLIDE_BODY_TOP = 76 # 사용자 직설 (divider-body 16px 여백) 2026-05-07
SLIDE_BODY_WIDTH = 1180 # calc(100% - 100px)
SLIDE_BODY_HEIGHT = 585 # 사용자 직설 (body-footer 10px 여백) 2026-05-07
SLIDE_FOOTER_HEIGHT = 41 # was 32, front 기준
GRID_GAP = 14 # zone 간격 (사용자 직설 2026-05-07)
# zone min-height fallback — contract 에 visual_hints.min_height_px 없을 때 사용.
# token-based font (var(--font-body) 11px 등) 기준 최소 가독 높이.
DEFAULT_ZONE_MIN_HEIGHT_PX = 100
# Step 14 image_aspect_mismatch tolerance — |natural_ratio - rendered_ratio| > TOL ⇒ fail.
# Local anchor : IMP-15 실행-1 (Gitea issue #45) — image axis acceptance criteria.
# Spec doc row (PHASE-Z-FIT-CLASSIFIER-ROUTER-SPEC) update deferred to IMP-15 실행-4.
IMAGE_ASPECT_DELTA_TOL = 0.05
# Step 14 table_self_overflow tolerance — scrollWclientW or scrollHclientH > TOL ⇒ fail.
# Local anchor : IMP-15 실행-2 (Gitea issue #46) — table axis acceptance criteria.
# Mirrors existing inline 5px tolerance used by slide/zone/clipped scans in run_overflow_check.
TABLE_SCROLL_TOL_PX = 5
# content_weight 계산 가중치
CONTENT_WEIGHT_COEFFS = {
"text_per_chars": 800, # text_len / 800 = score
"top_bullet": 0.4,
"nested_bullet": 0.15,
"table_bonus": 1.5,
"subsection": 0.6,
}
# ─── Data classes ───────────────────────────────────────────────
@dataclass
class MdxSection:
section_id: str
section_num: int
title: str
raw_content: str
# IMP-08 B-3 sub-section schema (additive, defaults preserve 4-positional callers).
# heading_number: decimal "2.1" from MDX `### 2.1 Title` capture (U2-populated).
# v4_alias_keys: legacy V4 keys to try when canonical ordinal id misses (e.g. "04-2.1").
# sub_sections: raw child payloads from section_parser (Stage 0 adapter consumes).
heading_number: Optional[str] = None
v4_alias_keys: list = field(default_factory=list)
sub_sections: list = field(default_factory=list)
@dataclass
class V4Match:
section_id: str
frame_id: str
frame_number: int
template_id: str
confidence: float
label: str
v4_rank: Optional[int] = None
selection_path: str = "rank_1"
fallback_reason: Optional[str] = None
# IMP-30 u1 — provisional first-render flag. True when the selector
# synthesizes a rank-1 V4 candidate after chain_exhausted because the
# opt-in allow_provisional kwarg was set. Default False keeps IMP-05
# behavior byte-identical; downstream surfaces this for zone-level
# "needs adaptation" marking without altering V4 evidence.
provisional: bool = False
def to_phase_z_status(match: V4Match) -> str:
return V4_LABEL_TO_PHASE_Z_STATUS.get(match.label, "unknown")
# ─── MDX parsing ────────────────────────────────────────────────
def parse_mdx(mdx_path: Path) -> tuple[str, list[MdxSection], Optional[str]]:
"""basic MDX parser — ## level sections only. V4 무관 (matching artifact 모름).
section.raw_content 에 ### sub-section 그대로 포함. V4 granularity 와 align 은
align_sections_to_v4_granularity() 가 처리.
"""
text = mdx_path.read_text(encoding="utf-8")
fm_match = re.match(r"^---\n(.*?)\n---\n", text, re.DOTALL)
slide_title = ""
if fm_match:
fm = yaml.safe_load(fm_match.group(1))
slide_title = fm.get("title", "")
text = text[fm_match.end():]
footer_match = re.search(r":::note\[[^\]]*\]\n(.*?)\n:::", text, re.DOTALL)
footer_text = None
if footer_match:
body = footer_match.group(1)
bullet_match = re.search(r"\*\s*\*\*([^*]+)\*\*", body)
footer_text = (bullet_match.group(1).strip() if bullet_match else body.strip())
text = text[:footer_match.start()] + text[footer_match.end():]
sections = []
section_pattern = re.compile(r"^##\s+(\d+)\.\s+(.+?)$", re.MULTILINE)
matches = list(section_pattern.finditer(text))
mdx_num_match = re.match(r"(\d+)", mdx_path.stem)
mdx_id = mdx_num_match.group(1).zfill(2) if mdx_num_match else "00"
for i, m in enumerate(matches):
section_num = int(m.group(1))
title_text = m.group(2).strip()
start = m.end()
end = matches[i + 1].start() if i + 1 < len(matches) else len(text)
raw_content = text[start:end].strip()
sections.append(MdxSection(
section_id=f"{mdx_id}-{section_num}",
section_num=section_num,
title=f"{section_num}. {title_text}",
raw_content=raw_content,
))
return slide_title, sections, footer_text
# IMP-02 (Phase Z Step 2) — Stage 0 normalize chained adapter.
# scope-lock 7 조건 (Gitea #2):
# 1. inline helper near parse_mdx()
# 2. PHASE_Z_STAGE0_ADAPTER_ENABLED env flag, default OFF (canary, matches PHASE_Z_B4_*)
# 3. env=1 sample verification required (in review loop)
# 4. fallback_reason: str | None flat — 5 hard cases
# 5. verify normalize_mdx_content(raw_mdx)["sections"] is list
# 6. preserve Step 2 existing fields; stage0_adapter_diagnostics additive only
# 7. out of scope: V4 / align / composition / AI/Kei / frame selection / status semantics
_STAGE0_FALLBACK_REASONS = {
"ADAPTER_EXCEPTION",
"NO_USABLE_SECTIONS",
"MISSING_INVALID_IDS",
"DUPLICATE_IDS",
"NON_POSITIVE_SECTION_NUM",
}
def _stage0_chained_adapter(
mdx_path: Path,
legacy_slide_title: str,
legacy_sections: list[MdxSection],
legacy_footer: Optional[str],
) -> tuple[str, list[MdxSection], Optional[str], dict, dict]:
"""IMP-02 — chained adapter for Stage 0 normalize → Phase Z Step 2 input.
Chain: mdx_normalizer.normalize_mdx_content + section_parser.extract_major_sections
+ section_parser.extract_conclusion_text → reconstructed MdxSection list.
Default OFF (canary, env=`1/true/yes` to enable). When OFF, returns legacy parse_mdx
output with diagnostics indicating disabled. When ON, runs adapter chain; on any
hard contract failure or exception, falls back to legacy and records fallback_reason.
Returns (slide_title, sections, footer, diagnostics, normalized_assets).
normalized_assets = {"popups": [...], "images": [...], "tables": [...]}
— IMP-03 Step 3 handoff. env=OFF or hard fallback 시 빈 list.
"""
diagnostics: dict = {
"enabled": False,
"used": False,
"fallback_reason": None,
"id_reconstruction_log": [],
"adapter_counts": None,
"legacy_counts": {"sections": len(legacy_sections)},
}
# IMP-03 — Step 3 handoff. env=OFF / fallback 시 모든 list 가 비어 있음.
normalized_assets: dict = {"popups": [], "images": [], "tables": []}
raw_flag = os.environ.get("PHASE_Z_STAGE0_ADAPTER_ENABLED", "").strip().lower()
enabled = raw_flag in {"1", "true", "yes"}
diagnostics["enabled"] = enabled
if not enabled:
return legacy_slide_title, legacy_sections, legacy_footer, diagnostics, normalized_assets
try:
# Defer imports — legacy path must not depend on these modules.
from mdx_normalizer import normalize_mdx_content
from section_parser import extract_conclusion_text, extract_major_sections
raw_mdx = mdx_path.read_text(encoding="utf-8")
normalized = normalize_mdx_content(raw_mdx)
if not isinstance(normalized, dict) or not isinstance(normalized.get("sections"), list):
diagnostics["fallback_reason"] = "MISSING_INVALID_IDS"
return legacy_slide_title, legacy_sections, legacy_footer, diagnostics, normalized_assets
majors = extract_major_sections(normalized["sections"])
if not majors:
diagnostics["fallback_reason"] = "NO_USABLE_SECTIONS"
return legacy_slide_title, legacy_sections, legacy_footer, diagnostics, normalized_assets
adapter_title = (normalized.get("title") or "").strip() or legacy_slide_title
conclusion = extract_conclusion_text(raw_mdx)
adapter_footer = conclusion if conclusion else None
mdx_num_match = re.match(r"(\d+)", mdx_path.stem)
mdx_id = mdx_num_match.group(1).zfill(2) if mdx_num_match else "00"
# Pre-scan raw MDX `## N. Title` headings → {title: section_num} map.
# Required to make scope-lock §5 "raw heading reuse first" functionally
# reachable, since extract_major_sections strips the leading `N.` from
# its level=2 group titles (Codex implementation review #6 catch).
raw_heading_map: dict[str, int] = {}
for h in re.finditer(r"^##\s+(\d+)\.\s+(.+?)$", raw_mdx, re.MULTILINE):
raw_heading_map[h.group(2).strip()] = int(h.group(1))
adapter_sections: list[MdxSection] = []
used_nums: set[int] = set()
for idx, m in enumerate(majors, start=1):
mtitle = (m.get("title") or "").strip()
content = (m.get("content") or "").strip()
if mtitle in raw_heading_map:
section_num = raw_heading_map[mtitle]
clean_title = mtitle
reuse_source = "raw_heading"
else:
inline_match = re.match(r"^(\d+)\.\s*(.+)$", mtitle)
if inline_match:
section_num = int(inline_match.group(1))
clean_title = inline_match.group(2).strip()
reuse_source = "raw_heading_inline"
else:
section_num = idx
clean_title = mtitle
reuse_source = "order_fallback"
if section_num <= 0:
diagnostics["fallback_reason"] = "NON_POSITIVE_SECTION_NUM"
return legacy_slide_title, legacy_sections, legacy_footer, diagnostics, normalized_assets
if section_num in used_nums:
diagnostics["fallback_reason"] = "DUPLICATE_IDS"
return legacy_slide_title, legacy_sections, legacy_footer, diagnostics, normalized_assets
used_nums.add(section_num)
diagnostics["id_reconstruction_log"].append({
"input_title": mtitle,
"section_num": section_num,
"reuse_source": reuse_source,
})
adapter_sections.append(MdxSection(
section_id=f"{mdx_id}-{section_num}",
section_num=section_num,
title=f"{section_num}. {clean_title}",
raw_content=content,
))
diagnostics["adapter_counts"] = {
"sections": len(adapter_sections),
"majors": len(majors),
"normalized_sections": len(normalized["sections"]),
"popups": len(normalized.get("popups", []) or []),
"images": len(normalized.get("images", []) or []),
"tables": len(normalized.get("tables", []) or []),
}
diagnostics["diff_vs_legacy"] = {
"title_match": adapter_title == legacy_slide_title,
"count_match": len(adapter_sections) == len(legacy_sections),
"footer_match": adapter_footer == legacy_footer,
}
diagnostics["used"] = True
# IMP-03 — populate Step 3 handoff (success path only).
# All fallback paths leave normalized_assets as empty lists (defined at fn top).
normalized_assets = {
"popups": normalized.get("popups", []) or [],
"images": normalized.get("images", []) or [],
"tables": normalized.get("tables", []) or [],
}
return adapter_title, adapter_sections, adapter_footer, diagnostics, normalized_assets
except Exception as exc: # noqa: BLE001 — adapter must never break legacy path
diagnostics["fallback_reason"] = "ADAPTER_EXCEPTION"
diagnostics["exception"] = repr(exc)
return legacy_slide_title, legacy_sections, legacy_footer, diagnostics, normalized_assets
# ─── V4 lookup ──────────────────────────────────────────────────
def load_v4_result() -> dict:
return yaml.safe_load(V4_RESULT_PATH.read_text(encoding="utf-8"))
def align_sections_to_v4_granularity(
sections: list[MdxSection],
v4: dict,
*,
override_target_section_ids: Optional[list[str]] = None,
) -> list[MdxSection]:
"""Align MDX sections to canonical sub-section granularity.
Default behaviour (V4-driven granularity, backward compatible) :
- V4 has section_id exact key -> keep section unchanged (parent
granularity rendering, parent-level V4 evidence applies).
- V4 missing + H3 sub-sections -> drill into sub-sections, emit
canonical ids ``${section_id}-sub-${ordinal}`` with optional
decimal alias for legacy V4 keys (e.g. ``04-2.1``).
- V4 missing + no H3 -> pass through (downstream V4 lookup
will naturally abort with no_v4_section).
IMP-08 B-3 / Stage 5 R2 blocker-fix — ``override_target_section_ids``
is the list of section ids that drag/drop override CLI flags target.
When any override target matches ``${section_id}-sub-N`` for a section
whose parent is otherwise V4-aligned, that section is force-drilled so
sub-section ids become addressable. This keeps the default rendering
path on V4 granularity while making drag/drop deterministic regardless
of whether V4 carries a parent exact key.
Each drilled sub-section carries :
- heading_number : decimal "2.1" / integer "1" / None (bare H3 title).
- v4_alias_keys : legacy V4 keys to try when the canonical ordinal
id misses. Populated only when ``heading_number`` matches the
decimal pattern ``\\d+\\.\\d+`` (N-R5 guard) — integer-only or
bare H3 produces no alias to avoid sibling-parent V4 collisions.
Design boundary :
- parser (``parse_mdx``) = MDX-only knowledge (V4-agnostic).
- aligner (this function) = canonical sub-id schema, MDX-driven on
force_drill, V4-driven otherwise.
- resolver (``_resolve_v4_section_key``) = exact > alias > None,
never auto-promotes to parent/sibling (axis 7 hybrid lock).
"""
v4_keys = set(v4.get("mdx_sections", {}).keys())
# Build the set of parent ids whose sub-ids are explicitly targeted by
# an override. These sections must be drilled even if V4 also carries
# the parent key exactly. Parents derived from canonical "X-sub-N" ids
# only — non-sub ids (top-level overrides) do not trigger drilling.
force_drill_parents: set[str] = set()
if override_target_section_ids:
for sid in override_target_section_ids:
parent = derive_parent_id(sid)
if parent and sid != parent:
force_drill_parents.add(parent)
aligned: list[MdxSection] = []
# Capture optional heading-number prefix (decimal "2.1" or integer "1")
# plus the heading title. None group = bare "### Title".
sub_pattern = re.compile(
r"^###\s+(?:(\d+(?:\.\d+)?)\s+)?(.+?)$", re.MULTILINE
)
decimal_re = re.compile(r"\d+\.\d+")
for section in sections:
force_drill = section.section_id in force_drill_parents
if section.section_id in v4_keys and not force_drill:
# V4 carries this section exactly and no override targets a
# sub-id under it: keep parent granularity (backward compat).
aligned.append(section)
continue
sub_matches = list(sub_pattern.finditer(section.raw_content))
if not sub_matches:
# No H3 sub-sections: cannot drill. Pass section through;
# downstream V4 lookup aborts with no_v4_section when needed.
aligned.append(section)
continue
mdx_id = section.section_id.split("-")[0] # e.g., "04"
for ordinal, m in enumerate(sub_matches, start=1):
heading_number = m.group(1) # decimal "2.1" / integer "1" / None
sub_title = m.group(2).strip()
start = m.end()
end = (
sub_matches[ordinal].start()
if ordinal < len(sub_matches)
else len(section.raw_content)
)
raw = section.raw_content[start:end].strip()
# N-R5 : alias only for decimal heading numbers. integer-only
# H3 (`### 1`) or undecorated H3 produce no alias to avoid
# sibling-parent V4 collisions (e.g., 05.mdx integer H3s).
alias_keys: list[str] = []
if heading_number and decimal_re.fullmatch(heading_number):
alias_keys.append(f"{mdx_id}-{heading_number}")
title = (
f"{heading_number} {sub_title}" if heading_number else sub_title
)
aligned.append(MdxSection(
section_id=f"{section.section_id}-sub-{ordinal}",
section_num=section.section_num,
title=title,
raw_content=raw,
heading_number=heading_number,
v4_alias_keys=alias_keys,
))
return aligned
def _v4_match_from_judgment(section_id: str, judgment: dict, rank: Optional[int] = None) -> V4Match:
resolved_rank = rank if rank is not None else judgment.get("v4_full_rank")
return V4Match(
section_id=section_id,
frame_id=str(judgment["frame_id"]),
frame_number=int(judgment["frame_number"]),
template_id=judgment["template_id"],
confidence=float(judgment["confidence"]),
label=judgment["label"],
v4_rank=int(resolved_rank) if resolved_rank is not None else None,
)
def _resolve_v4_section_key(
v4: dict,
section_id: str,
*,
alias_keys: Optional[list] = None,
) -> Optional[str]:
"""Resolve a V4 ``mdx_sections`` key for *section_id*.
Resolution order :
1. exact match (canonical ordinal id wins)
2. alias_keys in given order (e.g. legacy decimal ``04-2.1`` for ``04-2-sub-1``)
3. None on miss.
Never promotes to parent or sibling — that would reinterpret V4 evidence
(axis 7 hybrid lock, RULE 0). U1 callers pass alias_keys=None so the
function is byte-identical to the previous exact-match lookup; U2 populates
aliases from MDX heading_number metadata.
"""
keys = v4.get("mdx_sections", {})
if section_id in keys:
return section_id
if alias_keys:
for a in alias_keys:
if a and a in keys:
return a
return None
def lookup_v4_match(
v4: dict, section_id: str, *, alias_keys: Optional[list] = None
) -> Optional[V4Match]:
resolved = _resolve_v4_section_key(v4, section_id, alias_keys=alias_keys)
sec = v4.get("mdx_sections", {}).get(resolved) if resolved else None
if not sec:
return None
judgments = sec.get("judgments_full32", [])
if not judgments:
return None
top = judgments[0]
return _v4_match_from_judgment(section_id, top, rank=1)
# IMP-05 L2/L5 route hint — V4 label → execution route guidance for future consumers
# (frontend zone-level override / AI-assisted adaptation). Codex #2 conceptual model :
# use_as_is → Phase Z direct render
# light_edit → deterministic minor adjustment
# restructure → AI-assisted frame-aware adaptation (deferred to IMP-17 — carve-out, AI fallback only, normal path 밖)
# reject → AI re-construction over the rank-1 reject frame (IMP-47B u1, 2026-05-21);
# policy correction supersedes the legacy "design reference only" disposition.
# Frame visual / contract stays untouched; AI only re-maps MDX content into
# declared slots. Activation still gated by ai_fallback_enabled (default OFF).
_IMP05_ROUTE_HINTS: dict[str, str] = {
"use_as_is": "direct_render",
"light_edit": "deterministic_minor_adjustment",
"restructure": "ai_adaptation_required",
"reject": "ai_adaptation_required",
}
def _imp05_route_hint(label: Optional[str]) -> Optional[str]:
"""Map V4 label to execution route hint. Returns None for unknown labels."""
if label is None:
return None
return _IMP05_ROUTE_HINTS.get(label)
def _load_frame_partial_html(template_id: str) -> str:
"""IMP-47B u4 — Read templates/phase_z2/families/{template_id}.html.
Missing partial (e.g., ``__empty__`` shell from IMP-30) returns an
empty string so gather_step12_ai_repair_proposals can still build a
record with skip_reason without raising on file IO.
"""
partial_path = TEMPLATE_DIR / "families" / f"{template_id}.html"
if not partial_path.is_file():
return ""
return partial_path.read_text(encoding="utf-8")
def _run_step12_ai_repair(units) -> list[dict]:
"""IMP-47B u4 — Wire gather_step12_ai_repair_proposals into Step 12.
Routes provisional units whose IMP-05 hint maps to
``ai_adaptation_required`` (``restructure`` + ``reject`` per u1)
through ``src.phase_z2_ai_fallback.router``. Normal-path units
(``use_as_is`` / ``light_edit`` / non-provisional) record a
skip_reason without invoking the router; flag-off runs short-circuit
at the router (``settings.ai_fallback_enabled=False`` default).
Returns the per-unit record list — u5 consumes records for
PARTIAL_OVERRIDES apply and u6 writes the audit artifact.
"""
return gather_step12_ai_repair_proposals(
units,
route_for_label=_imp05_route_hint,
get_contract_fn=get_contract,
frame_visual_loader=_load_frame_partial_html,
)
_REJECT_SUPPORTED_PROPOSAL_KINDS: frozenset[str] = frozenset({"partial_overrides"})
def _apply_ai_repair_proposals_to_zones(
ai_repair_records: list[dict],
unit_positions: list[str],
zones_data: list[dict],
) -> None:
"""IMP-47B u5 — Apply PARTIAL_OVERRIDES into zones_data.slot_payload.
Mutates each record's ``apply_status`` in place and merges
``proposal.payload.slots`` into the matching zone. Out-of-scope
kinds (``builder_options_patch``, ``slot_mapping_proposal``)
loud-fail with ``unsupported_kind_for_reject_route:<kind>`` — zones
untouched (human_review surfacing → u8). IMP-33 u5 validator
guarantees declared-slot completeness, so ``dict.update`` is the
structural merge (``feedback_ai_isolation_contract``).
"""
zone_by_position = {z["position"]: z for z in zones_data}
for record in ai_repair_records:
proposal = record.get("proposal")
if proposal is None:
record["apply_status"] = "no_proposal"
continue
kind = proposal.get("proposal_kind")
if kind not in _REJECT_SUPPORTED_PROPOSAL_KINDS:
record["apply_status"] = f"unsupported_kind_for_reject_route:{kind}"
print(
f" [ai-repair-apply] unit {record['unit_index']} "
f"proposal_kind='{kind}' out-of-scope for reject route — "
"skipping apply; human_review required.",
file=sys.stderr,
)
continue
unit_index = record["unit_index"]
position = (
unit_positions[unit_index]
if 0 <= unit_index < len(unit_positions) else None
)
zone = zone_by_position.get(position) if position is not None else None
if zone is None:
record["apply_status"] = "no_zone_match"
continue
slots = (proposal.get("payload") or {}).get("slots") or {}
zone["slot_payload"].update(slots)
record["apply_status"] = "applied:partial_overrides"
def _check_post_ai_coverage_invariant(
units,
ai_repair_records: list[dict],
) -> dict:
"""IMP-47B u7 — Verify AI repair preserved every source_section_id.
Compares the union of unit-level ``source_section_ids`` (pre-AI) to
the union present on ``ai_repair_records`` post-apply. Per the AI
isolation contract + dropped 절대 룰
(``feedback_ai_isolation_contract``), AI repair never removes a
unit's section coverage. Any divergence indicates a regression that
u8 surfaces through ``slide_status.ai_repair_status``. The check is
structural (set membership); the per-record ``source_section_ids``
list is a copy populated by ``gather_step12_ai_repair_proposals``
(``step12.py:124``) so apply mutations cannot silently drop it.
"""
pre_ai_ids: set[str] = set()
for unit in units:
pre_ai_ids.update(getattr(unit, "source_section_ids", []) or [])
post_ai_ids: set[str] = set()
for record in ai_repair_records:
post_ai_ids.update(record.get("source_section_ids") or [])
dropped = sorted(pre_ai_ids - post_ai_ids)
return {
"pre_ai_section_ids": sorted(pre_ai_ids),
"post_ai_section_ids": sorted(post_ai_ids),
"dropped_section_ids": dropped,
"status": "ok" if not dropped else "violated",
}
def _persist_ai_repair_proposals_to_cache(
ai_repair_records: list[dict],
*,
visual_check_passed: bool,
user_approved: bool,
auto_cache: bool,
) -> None:
"""IMP-47B u13 — Persist applied AI repair proposals through IMP-46 gates.
Mutates each record in place with a ``cache_save_status`` axis.
Only records whose ``apply_status`` starts with ``"applied:"`` and
that still carry the original ``cache_key`` + ``fingerprints`` + a
serialized ``proposal`` dict are eligible — everything else marked
``not_applied``. Eligible records go through
``cache.save_proposal`` with the IMP-46 dual-gate truth table; the
helper catches :class:`AiFallbackCacheGateError` so a gate block is
surfaced (``gate_blocked:<reason>``) without raising into the
pipeline runtime (the cache is a hint, never a hard dependency —
cache.py contract). ``visual_check_passed`` is never bypassable;
``auto_cache=True`` bypasses ONLY the ``user_approved`` gate per
IMP-46 u5. Pure save layer: no AI call, no MDX touch.
"""
from src.phase_z2_ai_fallback.cache import (
AiFallbackCacheGateError,
save_proposal,
)
from src.phase_z2_ai_fallback.schema import AiFallbackProposal
for record in ai_repair_records:
apply_status = record.get("apply_status") or ""
proposal_dict = record.get("proposal")
cache_key = record.get("cache_key")
fingerprints = record.get("fingerprints")
if (
not apply_status.startswith("applied:")
or not isinstance(proposal_dict, dict)
or not cache_key
or not isinstance(fingerprints, dict)
):
record["cache_save_status"] = "not_applied"
continue
try:
proposal_obj = AiFallbackProposal.model_validate(proposal_dict)
except Exception as exc: # noqa: BLE001 — invalid payload → skip, never raise
record["cache_save_status"] = f"invalid_proposal:{type(exc).__name__}"
continue
try:
save_proposal(
cache_key,
proposal_obj,
visual_check_passed=visual_check_passed,
user_approved=user_approved,
auto_cache=auto_cache,
fingerprints=fingerprints,
)
except AiFallbackCacheGateError as gate_exc:
record["cache_save_status"] = f"gate_blocked:{gate_exc}"
continue
record["cache_save_status"] = "saved"
def _summarize_ai_repair_status(
ai_repair_records: list[dict],
coverage_invariant: dict,
) -> dict:
"""IMP-47B u8 — Classify Step 12 AI repair outcomes for slide_status surfacing.
Reads u4 gather ``error`` + u5 ``apply_status`` + u7 coverage_invariant
to derive a single ``ai_repair_status`` axis attached to
``slide_status``. Failure-axis priority (highest → lowest):
``error`` > ``coverage_violated`` > ``unsupported_kind`` > ``applied`` > ``ok``.
``human_review_required`` flips True on the three failure axes so the
frontend (u11) can surface a notification per the IMP-47B policy
("AI 호출 실패 / proposal validation 실패 / coverage 미달 → frontend notification").
Pure: no IO, no AI call.
"""
counts = {
"total": len(ai_repair_records),
"applied": 0,
"no_proposal": 0,
"no_zone_match": 0,
"unsupported_kind": 0,
"error": 0,
}
unsupported_records: list[dict] = []
error_records: list[dict] = []
for record in ai_repair_records:
if record.get("error"):
counts["error"] += 1
error_records.append({
"unit_index": record.get("unit_index"),
"source_section_ids": list(record.get("source_section_ids") or []),
"error": record.get("error"),
})
continue
apply_status = record.get("apply_status") or ""
if apply_status.startswith("applied:"):
counts["applied"] += 1
elif apply_status.startswith("unsupported_kind_for_reject_route:"):
counts["unsupported_kind"] += 1
unsupported_records.append({
"unit_index": record.get("unit_index"),
"source_section_ids": list(record.get("source_section_ids") or []),
"apply_status": apply_status,
})
elif apply_status == "no_zone_match":
counts["no_zone_match"] += 1
else:
counts["no_proposal"] += 1
coverage_status = (coverage_invariant or {}).get("status", "ok")
dropped = list((coverage_invariant or {}).get("dropped_section_ids") or [])
if counts["error"]:
status = "error"
elif coverage_status != "ok":
status = "coverage_violated"
elif counts["unsupported_kind"]:
status = "unsupported_kind"
elif counts["applied"]:
status = "applied"
else:
status = "ok"
return {
"status": status,
"counts": counts,
"unsupported_kind_records": unsupported_records,
"error_records": error_records,
"coverage_status": coverage_status,
"dropped_section_ids": dropped,
"human_review_required": status in {"error", "coverage_violated", "unsupported_kind"},
}
def lookup_v4_match_with_fallback(
v4: dict,
section_id: str,
*,
raw_content: Optional[str] = None,
max_rank: Optional[int] = None,
alias_keys: Optional[list] = None,
allow_provisional: bool = False,
) -> tuple[Optional[V4Match], dict]:
"""Select V4 rank-1, or promote rank-2..N when rank-1 is not auto-renderable.
This is an IMP-05 selector only. It uses existing V4 labels, frame-contract
presence, and the Phase Z capacity precheck; it does not call calculate_fit.
IMP-30 u1 — when ``allow_provisional=True`` and the rank-1..effective_max_rank
chain is exhausted (no candidate passes MVP1 filter + contract + capacity),
the selector synthesizes a *provisional* V4Match from the rank-1 judgment so
the first-render invariant can be satisfied downstream. The synthesized
match carries ``provisional=True``, ``selection_path="provisional_rank_1"``,
and ``fallback_reason`` mirrors the existing chain-exhaust reason. The
candidate trace shape is unchanged (synthetic injection only updates the
top-level ``selection_path`` + ``selected_*`` mirrors). When the rank-1
judgment itself is missing (``empty_v4_judgments`` / ``no_v4_section``),
no provisional is synthesized — the caller handles those cases with a
placeholder zone or empty-shell.
Default ``allow_provisional=False`` keeps the IMP-05 behavior byte-identical.
IMP-38 — dynamic effective max_rank via ``load_v4_fallback_policy()``
(4 round 합의 / Codex #1~#3 + Claude #1~#4 LOCK at #67 comment 23195):
- ``max_rank=None`` (default) → policy applied:
usable_count = candidates in rank 1..default_max_rank passing 3-tier
predicate (status in MVP1 + catalog registered + optional capacity).
usable_count >= usable_threshold → effective_max_rank = default_max_rank.
Otherwise → effective_max_rank = min(extended_max_rank,
len(judgments_full32)) = effective_extended_ceiling (Codex #2 정정).
- ``max_rank`` explicitly passed → caller_override: that value is used
as-is (backward compat for tests / explicit IMP-05/IMP-30 paths).
Trace gains 8 IMP-38 fields: ``requested_max_rank``, ``default_max_rank``,
``configured_extended_max_rank``, ``judgments_count``,
``effective_extended_ceiling``, ``effective_max_rank``, ``usable_count``,
``policy_applied``. ``max_rank`` legacy field kept as alias for backward
compat (= effective_max_rank).
"""
resolved = _resolve_v4_section_key(v4, section_id, alias_keys=alias_keys)
sec = v4.get("mdx_sections", {}).get(resolved) if resolved else None
all_judgments = (sec.get("judgments_full32") if sec else None) or []
judgments_count = len(all_judgments)
# IMP-38 — load policy (graceful: yaml 없을 시 default_max_rank=3, extended=3)
_policy = load_v4_fallback_policy()
default_max_rank = int(_policy.get("default_max_rank", 3))
configured_extended_max_rank = int(_policy.get("extended_max_rank", default_max_rank))
usable_threshold = int(_policy.get("usable_threshold", 1))
# Codex #2 정정: min(configured, len(judgments_full32)) — yaml ceiling 무력화 방지
effective_extended_ceiling = min(configured_extended_max_rank, judgments_count) if judgments_count else default_max_rank
usable_count: Optional[int] = None # set only when policy path active
if max_rank is not None:
# caller override (backward compat — explicit IMP-05/IMP-30 paths, tests)
effective_max_rank = int(max_rank)
policy_applied = "caller_override"
elif judgments_count == 0:
# no judgments — slicing 빈 list 라 어차피 영향 X
effective_max_rank = default_max_rank
policy_applied = "no_judgments"
else:
# IMP-38 policy path — 3-tier predicate usable_count on default window
usable_count = 0
default_window = all_judgments[:default_max_rank]
for _j in default_window:
_m = _v4_match_from_judgment(section_id, _j, rank=0)
if to_phase_z_status(_m) not in MVP1_ALLOWED_STATUSES:
continue
if get_contract(_m.template_id) is None:
continue
if raw_content is not None:
_cap = compute_capacity_fit(_m.template_id, raw_content)
if _cap and _cap.get("fit_status") not in {
"ok", "no_contract", "unknown_source_shape",
}:
continue
usable_count += 1
if usable_count >= usable_threshold:
effective_max_rank = default_max_rank
policy_applied = "default_max_rank"
else:
effective_max_rank = effective_extended_ceiling
policy_applied = "extended_max_rank"
trace = {
"section_id": section_id,
# IMP-38 — 8 trace fields (4 round LOCK)
"requested_max_rank": max_rank,
"default_max_rank": default_max_rank,
"configured_extended_max_rank": configured_extended_max_rank,
"judgments_count": judgments_count,
"effective_extended_ceiling": effective_extended_ceiling,
"effective_max_rank": effective_max_rank,
"usable_count": usable_count,
"policy_applied": policy_applied,
# legacy alias for backward compat (= effective_max_rank)
"max_rank": effective_max_rank,
"selection_path": "no_v4_candidate",
"selected_rank": None,
"selected_template_id": None,
"selected_frame_id": None,
"selected_label": None,
"fallback_used": False,
"fallback_reason": None,
"candidates": [],
}
if not sec:
trace["fallback_reason"] = "no_v4_section"
return None, trace
judgments = all_judgments[:effective_max_rank]
if not judgments:
trace["fallback_reason"] = "empty_v4_judgments"
return None, trace
first_skip_reason: Optional[str] = None
# IMP-05 L4 dedup (Codex #14 ordering — Claude #16 placement precision) :
# first occurrence claims template_id for the chain regardless of decision
# (selected/non-direct/rejected/missing-contract/capacity-skipped). Defensive
# against V4 anomaly where same template_id appears at multiple ranks with
# different labels — first label/reason is preserved, later duplicates skip.
seen_template_ids: set[str] = set()
for i, judgment in enumerate(judgments, start=1):
match = _v4_match_from_judgment(section_id, judgment, rank=i)
status = to_phase_z_status(match)
# IMP-05 L2 (Codex #10 E4) — informative candidate_evidence schema.
# `v4_label` naming matches Codex schema (Claude #13 §1 lock).
# `filtered_for_direct_execution` + `route_hint` = L5 restructure/reject trace 보존
# 단일 source (frontend/AI future consumer guidance).
is_direct_eligible = status in MVP1_ALLOWED_STATUSES
candidate_trace = {
"rank": i,
"template_id": match.template_id,
"frame_id": match.frame_id,
"frame_number": match.frame_number,
"confidence": match.confidence,
"label": match.label, # existing — kept for backward compat
"v4_label": match.label, # IMP-05 L2 alias (Codex schema)
"phase_z_status": status,
"catalog_registered": get_contract(match.template_id) is not None,
"filtered_for_direct_execution": not is_direct_eligible, # IMP-05 L2/L5
"route_hint": _imp05_route_hint(match.label), # IMP-05 L2/L5
"decision": "skipped",
"reason": None,
}
# IMP-05 L4 dedup — duplicate check BEFORE rank evaluation.
# First occurrence reserves template_id even if non-direct/rejected/skipped.
# Later rank with same template_id is skipped as duplicate, audit fields preserved.
if match.template_id in seen_template_ids:
candidate_trace["reason"] = "duplicate_template_id"
trace["candidates"].append(candidate_trace)
continue
seen_template_ids.add(match.template_id)
if status not in MVP1_ALLOWED_STATUSES:
candidate_trace["reason"] = f"phase_z_status_not_allowed:{status}"
elif get_contract(match.template_id) is None:
candidate_trace["reason"] = "skipped_no_contract"
else:
capacity_fit = None
if raw_content is not None:
capacity_fit = compute_capacity_fit(match.template_id, raw_content)
candidate_trace["capacity_fit"] = capacity_fit
if capacity_fit and capacity_fit.get("fit_status") not in {
"ok", "no_contract", "unknown_source_shape",
}:
candidate_trace["reason"] = f"capacity_mismatch:{capacity_fit.get('fit_status')}"
else:
fallback_used = i > 1
fallback_reason = first_skip_reason if fallback_used else None
match.selection_path = f"rank_{i}" if not fallback_used else f"rank_{i}_fallback"
match.fallback_reason = fallback_reason
candidate_trace["decision"] = "selected"
candidate_trace["reason"] = "primary_selected" if i == 1 else "fallback_selected"
trace["candidates"].append(candidate_trace)
trace.update({
"selection_path": match.selection_path,
"selected_rank": i,
"selected_template_id": match.template_id,
"selected_frame_id": match.frame_id,
"selected_label": match.label,
"fallback_used": fallback_used,
"fallback_reason": fallback_reason,
})
return match, trace
if i == 1:
first_skip_reason = candidate_trace["reason"]
trace["candidates"].append(candidate_trace)
trace["selection_path"] = "chain_exhausted"
trace["fallback_reason"] = first_skip_reason or f"no_auto_renderable_rank_1_to_{effective_max_rank}"
# IMP-30 u1 — opt-in provisional first-render synthesis. When the caller
# signals allow_provisional, promote rank-1 judgment as a provisional
# match so downstream composition can satisfy the first-render invariant.
# Top-level mirrors (selection_path / selected_*) are updated; candidate
# trace entries are left intact (their skip reasons remain accurate).
# Default-off keeps IMP-05 behavior byte-identical.
if allow_provisional:
rank_1_judgment = judgments[0]
provisional_match = _v4_match_from_judgment(
section_id, rank_1_judgment, rank=1
)
provisional_match.selection_path = "provisional_rank_1"
provisional_match.fallback_reason = trace["fallback_reason"]
provisional_match.provisional = True
trace.update({
"selection_path": "provisional_rank_1",
"selected_rank": 1,
"selected_template_id": provisional_match.template_id,
"selected_frame_id": provisional_match.frame_id,
"selected_label": provisional_match.label,
"fallback_used": True,
"provisional": True,
})
return provisional_match, trace
return None, trace
def lookup_v4_all_judgments(
v4: dict, section_id: str, *, alias_keys: Optional[list] = None
) -> list[V4Match]:
"""V4 raw 32 entry 그대로 반환 — reject 포함, max_n filter 없음.
Step 7-A axis 보강 (사용자 lock 2026-05-08) — 사용자 UI 가 모든 frame 의
png 를 보여줄 수 있도록 reject 까지 trace. lookup_v4_candidates 는 변경 없음
(backward compat — non-reject + max_n 만 반환).
Returns :
list[V4Match] — 0~32 길이. raw judgments_full32 순서 (= V4 score desc) 보존.
"""
resolved = _resolve_v4_section_key(v4, section_id, alias_keys=alias_keys)
sec = v4.get("mdx_sections", {}).get(resolved) if resolved else None
if not sec:
return []
judgments = sec.get("judgments_full32", [])
out: list[V4Match] = []
for j in judgments:
out.append(_v4_match_from_judgment(section_id, j))
return out
def lookup_v4_candidates(
v4: dict,
section_id: str,
max_n: int = 6,
*,
alias_keys: Optional[list] = None,
) -> 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.
"""
resolved = _resolve_v4_section_key(v4, section_id, alias_keys=alias_keys)
sec = v4.get("mdx_sections", {}).get(resolved) if resolved else None
if not sec:
return []
judgments = sec.get("judgments_full32", [])
candidates: list[V4Match] = []
for j in judgments:
if j.get("label") == "reject":
continue
candidates.append(_v4_match_from_judgment(section_id, j))
if len(candidates) >= max_n:
break
return candidates
def _apply_frame_override_to_unit(unit, new_tid: str, v4: dict) -> str:
"""IMP-47B u3 — apply a frame override to *unit* in place.
Returns a meta_source string for the override book-keeping. Three
probe layers, in order:
1. ``unit.v4_candidates`` (non-reject, max_n bounded). Copies
frame_id / frame_number / confidence / label from the matching
candidate so Step 9 metadata stays consistent. Returns
``"v4_candidates"``.
2. Full 32 V4 judgments (reject inclusive). When the override
target matches a reject judgment for the unit's primary section,
the unit is promoted to ``provisional=True`` with ``label="reject"``
so Step 12 (IMP-47B u4) admits the AI repair path. Returns
``"v4_reject_judgment_provisional"``.
3. Raw fall-through. Updates only ``frame_template_id``; returns
``"raw_template_id_only"``.
Frame visual / contract stay untouched per the AI isolation contract
(frame auto-swap forbidden — AI re-places content into the existing
frame only). The caller validates catalog contract presence before
invoking this helper.
"""
for cand in (unit.v4_candidates or []):
if getattr(cand, "template_id", None) == new_tid:
unit.frame_template_id = cand.template_id
unit.frame_id = cand.frame_id
unit.frame_number = cand.frame_number
unit.confidence = cand.confidence
unit.label = cand.label
return "v4_candidates"
primary_sid = (
unit.source_section_ids[0] if unit.source_section_ids else None
)
if primary_sid:
for j in lookup_v4_all_judgments(v4, primary_sid):
if j.template_id == new_tid and j.label == "reject":
unit.frame_template_id = j.template_id
unit.frame_id = j.frame_id
unit.frame_number = j.frame_number
unit.confidence = j.confidence
unit.label = "reject"
unit.provisional = True
return "v4_reject_judgment_provisional"
unit.frame_template_id = new_tid
return "raw_template_id_only"
# ─── Content weight + zone layout 계산 ─────────────────────────
# layout preset 선택은 phase_z2_composition.select_layout_preset (composition v0) 가 담당.
# 본 모듈의 select_layout_preset 은 이전 단순 count-based 구현이었고 dead code 로 제거 (2026-04-29).
def compute_content_weight(section: MdxSection) -> dict:
"""Section 의 콘텐츠 부피 측정 — text/bullet/table/subsection 합성 score."""
text = section.raw_content
lines = text.splitlines()
text_len = len(text)
top_bullets = sum(1 for l in lines if re.match(r"^[\*\-]\s", l))
nested_bullets = sum(1 for l in lines if re.match(r"^\s+[\*\-]\s", l))
has_table = bool(re.search(r"\|[^\n]+\|\n[ \t]*\|[\s\-:|]+\|", text))
subsections = len(re.findall(r"^###\s", text, re.MULTILINE))
c = CONTENT_WEIGHT_COEFFS
score = (
text_len / c["text_per_chars"]
+ top_bullets * c["top_bullet"]
+ nested_bullets * c["nested_bullet"]
+ (c["table_bonus"] if has_table else 0)
+ subsections * c["subsection"]
)
return {
"score": round(score, 3),
"text_length": text_len,
"top_bullets": top_bullets,
"nested_bullets": nested_bullets,
"has_table": has_table,
"subsection_count": subsections,
}
def compute_zone_layout(zones_data: list[dict],
total_height: int = SLIDE_BODY_HEIGHT,
gap: int = GRID_GAP) -> dict:
"""zone height 계산 — frame_min_height_px 우선 + 남은 공간 content_weight 비율 분배.
Returns dict with per-zone heights + reasoning trace.
"""
n = len(zones_data)
if n == 0:
return {"heights_px": [], "ratios": [], "zones": []}
available = total_height - gap * (n - 1)
# Step 1: 각 zone 의 min_height 할당 — pipeline 가 zones_data 에 frame contract 의
# visual_hints.min_height_px 를 미리 주입했음. 없으면 DEFAULT_ZONE_MIN_HEIGHT_PX.
min_heights = [
z.get("min_height_px", DEFAULT_ZONE_MIN_HEIGHT_PX)
for z in zones_data
]
total_min = sum(min_heights)
min_scaled = False
if total_min > available:
scale = available / total_min
min_heights = [int(m * scale) for m in min_heights]
total_min = sum(min_heights)
min_scaled = True
remaining = available - total_min
# Step 2: 남은 공간을 content_weight 비율로 분배
weights = [z["content_weight"]["score"] for z in zones_data]
total_w = sum(weights) if sum(weights) > 0 else n
extras = [int(round(remaining * (w / total_w))) for w in weights]
# Step 3: rounding 보정 (마지막 zone 잔여 흡수)
heights = [m + e for m, e in zip(min_heights, extras)]
diff = available - sum(heights)
if diff != 0 and heights:
heights[-1] += diff
ratios = [round(h / total_height, 3) for h in heights]
return {
"computation": "min_height_first + content_weight_distribution",
"slide_body_height": total_height,
"gap": gap,
"available_after_gap": available,
"min_heights_px": min_heights,
"min_scaled": min_scaled,
"total_min_height": total_min,
"remaining_after_min": remaining,
"content_weights": [{"position": z["position"],
"template_id": z["template_id"],
"score": w}
for z, w in zip(zones_data, weights)],
"weight_shares": [round(w / total_w, 3) for w in weights],
"extras_px": extras,
"heights_px": heights,
"ratios": ratios,
}
# ─── IMP-09 PR 1 helpers (8-preset layout vocabulary) ────────────────
# Catalog css_areas / css_cols / css_rows parsing + per-zone aggregation
# + col-axis solver. Symmetric counterparts to compute_zone_layout (row-axis).
def _parse_css_areas(css_areas: str) -> tuple[list[list[str]], list[str]]:
"""Parse CSS grid-template-areas string into (row x col) cell grid.
Input : '"top top" "bottom-left bottom-right"'
Output : (
[["top", "top"], ["bottom-left", "bottom-right"]],
["top", "bottom-left", "bottom-right"],
)
Raises ValueError on empty input, missing quotes, empty row, or
non-rectangular layout (rows with mismatched column counts).
"""
rows: list[list[str]] = []
seen: list[str] = []
quoted = re.findall(r'"([^"]+)"', css_areas)
if not quoted:
raise ValueError(
f"_parse_css_areas: no quoted row strings found in {css_areas!r}"
)
for row_str in quoted:
tokens = row_str.split()
if not tokens:
raise ValueError(
f"_parse_css_areas: empty row in {css_areas!r}"
)
rows.append(tokens)
for token in tokens:
if token not in seen:
seen.append(token)
col_counts = {len(r) for r in rows}
if len(col_counts) > 1:
raise ValueError(
f"_parse_css_areas: non-rectangular grid, row column counts = "
f"{col_counts} in {css_areas!r}"
)
return rows, seen
def _parse_fr_string(spec: str, total: int) -> list[int]:
"""Parse '1fr' / '1fr 1fr' / 'Nfr Mfr' into integer px lengths.
Catalog presets (templates/phase_z2/layouts/layouts.yaml) only use
1fr-only specs; mixed px/fr is out of scope. Raises ValueError on
non-fr tokens or zero total.
"""
fractions: list[float] = []
for token in spec.split():
m = re.fullmatch(r"(\d+(?:\.\d+)?)fr", token)
if not m:
raise ValueError(
f"_parse_fr_string: non-fr token {token!r} in {spec!r}"
)
fractions.append(float(m.group(1)))
if not fractions:
raise ValueError(f"_parse_fr_string: empty spec {spec!r}")
total_fr = sum(fractions)
if total_fr <= 0:
raise ValueError(f"_parse_fr_string: total fr = 0 in {spec!r}")
sizes = [int(round(total * (f / total_fr))) for f in fractions]
sizes[-1] += total - sum(sizes)
return sizes
def compute_zone_layout_cols(zones_data: list[dict],
total_width: int = SLIDE_BODY_WIDTH,
gap: int = GRID_GAP) -> dict:
"""Per-zone column width allocation — weight-only distribution.
Symmetric counterpart of compute_zone_layout for the column axis.
No min_width_px contract exists in frame_contracts.yaml (verified
empty as of IMP-09), so column allocation is purely content_weight
score based.
"""
n = len(zones_data)
if n == 0:
return {"widths_px": [], "width_ratios": [], "zones": []}
available = total_width - gap * (n - 1)
weights = [z["content_weight"]["score"] for z in zones_data]
total_w = sum(weights)
if total_w <= 0:
# Zero-weight guard (override-empty zone where score=0).
widths_px = [available // n] * n
widths_px[-1] += available - sum(widths_px)
weight_shares = [round(1.0 / n, 3)] * n
else:
widths_px = [
int(round(available * (w / total_w))) for w in weights
]
diff = available - sum(widths_px)
if diff != 0:
widths_px[-1] += diff
weight_shares = [round(w / total_w, 3) for w in weights]
width_ratios = [round(w / total_width, 3) for w in widths_px]
return {
"computation": "content_weight_distribution_cols",
"slide_body_width": total_width,
"gap": gap,
"available_after_gap": available,
"content_weights": [
{"position": z["position"],
"template_id": z["template_id"],
"score": w}
for z, w in zip(zones_data, weights)
],
"weight_shares": weight_shares,
"widths_px": widths_px,
"width_ratios": width_ratios,
}
def _aggregate_zone_signals_per_track(
preset: dict,
zones_data: list[dict],
) -> tuple[list[dict], list[dict]]:
"""Build per-row + per-col virtual zones for 2-D dynamic dispatch.
Each virtual zone aggregates content_weight.score (max) and
min_height_px (max) across single-span zones on that track
(occupied_rows == {r} for rows, occupied_cols == {c} for cols).
Falls back to all-span zones (touching every track on the axis)
when a track has no single-span zone.
"""
rows_grid, _ = _parse_css_areas(preset["css_areas"])
R = len(rows_grid)
C = len(rows_grid[0])
occupancy: list[tuple[dict, set[int], set[int]]] = []
for z in zones_data:
pos = z["position"]
occ_rows = {r for r, row in enumerate(rows_grid) if pos in row}
occ_cols = {
c for row in rows_grid for c, tok in enumerate(row) if tok == pos
}
occupancy.append((z, occ_rows, occ_cols))
def _track_virtual(idx: int, axis: str) -> dict:
if axis == "row":
single = [z for z, rr, _cc in occupancy if rr == {idx}]
allspan = [z for z, rr, _cc in occupancy if rr == set(range(R))]
else:
single = [z for z, _rr, cc in occupancy if cc == {idx}]
allspan = [z for z, _rr, cc in occupancy if cc == set(range(C))]
candidates = single or allspan
return {
"position": f"_virtual_{axis}_{idx}",
"template_id": f"_virtual_{axis}_{idx}",
"content_weight": {
"score": max(c["content_weight"]["score"] for c in candidates)
},
"min_height_px": max(
c.get("min_height_px", DEFAULT_ZONE_MIN_HEIGHT_PX)
for c in candidates
),
}
row_virtuals = [_track_virtual(r, "row") for r in range(R)]
col_virtuals = [_track_virtual(c, "col") for c in range(C)]
return row_virtuals, col_virtuals
def _compute_per_zone_geometry(
layout_css: dict,
debug_zones: list[dict],
gap: int = GRID_GAP,
) -> list[dict]:
"""Aggregate grid-track sizes into per-zone dimensions for ALL layouts.
Parses layout_css["areas"] (catalog css_areas) into an R x C cell
grid, then for each zone in debug_zones sums the heights_px of its
occupied rows and widths_px of its occupied columns, including the
inter-track gap absorbed by a spanning zone.
Length contract: layout_css["heights_px"] MUST have length R, and
layout_css["widths_px"] MUST have length C. Mismatch raises
ValueError because that indicates a broken build path, not a
runtime input issue.
"""
rows_grid, _ = _parse_css_areas(layout_css["areas"])
R = len(rows_grid)
C = len(rows_grid[0])
heights_px = layout_css.get("heights_px") or []
widths_px = layout_css.get("widths_px") or []
if len(heights_px) != R:
raise ValueError(
f"_compute_per_zone_geometry: heights_px length "
f"{len(heights_px)} != grid rows R={R} "
f"(css_areas={layout_css.get('areas')!r})"
)
if len(widths_px) != C:
raise ValueError(
f"_compute_per_zone_geometry: widths_px length "
f"{len(widths_px)} != grid cols C={C} "
f"(css_areas={layout_css.get('areas')!r})"
)
per_zone: list[dict] = []
for dz in debug_zones:
pos = dz["position"]
occupied_rows = sorted(
{r for r, row in enumerate(rows_grid) if pos in row}
)
occupied_cols = sorted(
{c for r, row in enumerate(rows_grid)
for c, tok in enumerate(row) if tok == pos}
)
if not occupied_rows or not occupied_cols:
raise ValueError(
f"_compute_per_zone_geometry: zone position {pos!r} "
f"not present in css_areas {rows_grid}"
)
zh = (
sum(heights_px[r] for r in occupied_rows)
+ gap * (len(occupied_rows) - 1)
)
zw = (
sum(widths_px[c] for c in occupied_cols)
+ gap * (len(occupied_cols) - 1)
)
per_zone.append({
"position": pos,
"zone_height_px": zh,
"zone_width_px": zw,
"zone_height_ratio": round(zh / SLIDE_BODY_HEIGHT, 3),
"zone_width_ratio": round(zw / SLIDE_BODY_WIDTH, 3),
})
return per_zone
def _build_fr_default(preset: dict) -> dict:
"""fr-default sink — populate widths_px / heights_px from catalog fr ratios.
Replaces the legacy empty-array sink so that downstream consumers
(Step 7/8 trace, _compute_per_zone_geometry) always receive
length-locked arrays matching the catalog grid dimensions.
"""
rows_grid, _ = _parse_css_areas(preset["css_areas"])
R = len(rows_grid)
C = len(rows_grid[0])
avail_h = SLIDE_BODY_HEIGHT - GRID_GAP * (R - 1)
avail_w = SLIDE_BODY_WIDTH - GRID_GAP * (C - 1)
heights_px = _parse_fr_string(preset["css_rows"], avail_h)
widths_px = _parse_fr_string(preset["css_cols"], avail_w)
return {
"areas": preset["css_areas"],
"cols": preset["css_cols"],
"rows": preset["css_rows"],
"heights_px": heights_px,
"widths_px": widths_px,
"ratios": [round(h / SLIDE_BODY_HEIGHT, 3) for h in heights_px],
"width_ratios": [round(w / SLIDE_BODY_WIDTH, 3) for w in widths_px],
"computation": "fr_default_from_preset",
"dynamic_rows": False,
"dynamic_cols": False,
"raw_zone_layout": None,
}
def _build_rows_dynamic(preset: dict, zones_data: list[dict],
gap: int = GRID_GAP) -> dict:
"""horizontal-2 path — dynamic row heights, static fr column widths.
Preserves the legacy compute_zone_layout output (heights_px / ratios /
computation / raw_zone_layout) byte-for-byte; only adds the new
col-axis keys (widths_px from css_cols fr, width_ratios, dynamic_cols=False).
"""
rows_grid, _ = _parse_css_areas(preset["css_areas"])
C = len(rows_grid[0])
avail_w = SLIDE_BODY_WIDTH - gap * (C - 1)
widths_px = _parse_fr_string(preset["css_cols"], avail_w)
zl = compute_zone_layout(zones_data, gap=gap)
rows_str = " ".join(f"{h}px" for h in zl["heights_px"])
return {
"areas": preset["css_areas"],
"cols": preset["css_cols"],
"rows": rows_str,
"heights_px": zl["heights_px"],
"widths_px": widths_px,
"ratios": zl["ratios"],
"width_ratios": [round(w / SLIDE_BODY_WIDTH, 3) for w in widths_px],
"computation": zl["computation"],
"dynamic_rows": True,
"dynamic_cols": False,
"raw_zone_layout": zl,
}
def _build_grid_dynamic_2d(preset: dict, zones_data: list[dict],
gap: int = GRID_GAP) -> dict:
"""2-D dynamic path — dynamic row heights + dynamic column widths.
IMP-09 PR 2 (B-4) handler for the five preset families whose topology
is neither pure 'rows' nor pure 'cols':
- T (top-1-bottom-2)
- inverted-T (top-2-bottom-1)
- side-T-left (left-1-right-2)
- side-T-right (left-2-right-1)
- 2x2 (grid-2x2)
Strategy:
1) _aggregate_zone_signals_per_track builds R per-row + C per-col
virtual zones (max content_weight.score + max min_height_px of
single-span zones, falling back to all-span zones).
2) Row virtuals → compute_zone_layout → heights_px (R).
3) Col virtuals → compute_zone_layout_cols → widths_px (C).
4) Assemble layout_css dict with computation='2d_dynamic_aggregated'
and dynamic_rows=True, dynamic_cols=True.
raw_zone_layout carries both solver outputs + the virtual zone lists
so step08 trace can explain the per-track aggregation.
"""
row_virtuals, col_virtuals = _aggregate_zone_signals_per_track(
preset, zones_data
)
zl_row = compute_zone_layout(row_virtuals, gap=gap)
zl_col = compute_zone_layout_cols(col_virtuals, gap=gap)
heights_px = zl_row["heights_px"]
widths_px = zl_col["widths_px"]
rows_str = " ".join(f"{h}px" for h in heights_px)
cols_str = " ".join(f"{w}px" for w in widths_px)
return {
"areas": preset["css_areas"],
"cols": cols_str,
"rows": rows_str,
"heights_px": heights_px,
"widths_px": widths_px,
"ratios": [round(h / SLIDE_BODY_HEIGHT, 3) for h in heights_px],
"width_ratios": [round(w / SLIDE_BODY_WIDTH, 3) for w in widths_px],
"computation": "2d_dynamic_aggregated",
"dynamic_rows": True,
"dynamic_cols": True,
"raw_zone_layout": {
"row_layout": zl_row,
"col_layout": zl_col,
"row_virtuals": row_virtuals,
"col_virtuals": col_virtuals,
},
}
def _build_cols_dynamic(preset: dict, zones_data: list[dict],
gap: int = GRID_GAP) -> dict:
"""vertical-2 path — dynamic column widths, static fr row heights.
Mirror of _build_rows_dynamic. Returns a pixel grid-template-columns
string. PR 2 promotes vertical-2 override to dynamic_rows=True; in
PR 1 dynamic_rows stays False (legacy).
"""
rows_grid, _ = _parse_css_areas(preset["css_areas"])
R = len(rows_grid)
avail_h = SLIDE_BODY_HEIGHT - gap * (R - 1)
heights_px = _parse_fr_string(preset["css_rows"], avail_h)
zl = compute_zone_layout_cols(zones_data, gap=gap)
cols_str = " ".join(f"{w}px" for w in zl["widths_px"])
return {
"areas": preset["css_areas"],
"cols": cols_str,
"rows": preset["css_rows"],
"heights_px": heights_px,
"widths_px": zl["widths_px"],
"ratios": [round(h / SLIDE_BODY_HEIGHT, 3) for h in heights_px],
"width_ratios": zl["width_ratios"],
"computation": zl["computation"],
"dynamic_rows": False,
"dynamic_cols": True,
"raw_zone_layout": zl,
}
def _override_to_grid_tracks(
preset: dict,
zones_data: list[dict],
override_zone_geometries: dict[str, dict],
gap: int = GRID_GAP,
) -> dict:
"""2-D override path — derive heights_px (R) + widths_px (C) from
user-supplied zone_id -> {x, y, w, h} (0~1 within slide-body).
IMP-09 PR 2 (B-4) override handler for the five preset families
whose topology is neither pure 'rows' nor pure 'cols':
- T (top-1-bottom-2)
- inverted-T (top-2-bottom-1)
- side-T-left (left-1-right-2)
- side-T-right (left-2-right-1)
- 2x2 (grid-2x2)
Strategy:
1) Parse css_areas into R x C grid.
2) For each row r: aggregate h via max over single-row zones
(occupied_rows == {r}); fallback to all-span zones; else 0.0.
3) For each col c: same with w.
4) Normalize per-axis (divide by total) and multiply by avail_*,
absorbing rounding diff into the last element.
5) If total_h or total_w == 0 (degenerate / empty override),
fall back to _build_grid_dynamic_2d default path.
"""
rows_grid, _ = _parse_css_areas(preset["css_areas"])
R = len(rows_grid)
C = len(rows_grid[0])
occupancy: list[tuple[dict, set[int], set[int]]] = []
for z in zones_data:
pos = z["position"]
occ_rows = {r for r, row in enumerate(rows_grid) if pos in row}
occ_cols = {
c for row in rows_grid for c, tok in enumerate(row) if tok == pos
}
occupancy.append((z, occ_rows, occ_cols))
def _track_value(idx: int, axis: str) -> float:
if axis == "row":
single = [z for z, rr, _cc in occupancy if rr == {idx}]
allspan = [z for z, rr, _cc in occupancy if rr == set(range(R))]
key = "h"
else:
single = [z for z, _rr, cc in occupancy if cc == {idx}]
allspan = [z for z, _rr, cc in occupancy if cc == set(range(C))]
key = "w"
candidates = single or allspan
vals = [
float(override_zone_geometries[z["position"]][key])
for z in candidates
if z["position"] in override_zone_geometries
]
return max(vals) if vals else 0.0
row_values = [_track_value(r, "row") for r in range(R)]
col_values = [_track_value(c, "col") for c in range(C)]
total_h = sum(row_values)
total_w = sum(col_values)
if total_h == 0 or total_w == 0:
return _build_grid_dynamic_2d(preset, zones_data, gap=gap)
row_ratios = [v / total_h for v in row_values]
col_ratios = [v / total_w for v in col_values]
avail_h = SLIDE_BODY_HEIGHT - gap * (R - 1)
avail_w = SLIDE_BODY_WIDTH - gap * (C - 1)
heights_px = [int(round(r * avail_h)) for r in row_ratios]
widths_px = [int(round(r * avail_w)) for r in col_ratios]
diff_h = avail_h - sum(heights_px)
if diff_h != 0 and heights_px:
heights_px[-1] += diff_h
diff_w = avail_w - sum(widths_px)
if diff_w != 0 and widths_px:
widths_px[-1] += diff_w
rows_str = " ".join(f"{h}px" for h in heights_px)
cols_str = " ".join(f"{w}px" for w in widths_px)
return {
"areas": preset["css_areas"],
"cols": cols_str,
"rows": rows_str,
"heights_px": heights_px,
"widths_px": widths_px,
"ratios": [round(rr, 3) for rr in row_ratios],
"width_ratios": [round(rr, 3) for rr in col_ratios],
"computation": "user_override_geometry",
"dynamic_rows": True,
"dynamic_cols": True,
"raw_zone_layout": {
"override_applied": True,
"source": override_zone_geometries,
},
}
# Layout preset → zone position 순서 = LAYOUT_PRESETS[preset]["positions"] 직접 사용.
# 이전 ZONE_POSITIONS_BY_PRESET (type-b 등 legacy 명) 는 dead code 로 제거 (2026-04-29).
def build_layout_css(layout_preset: str, zones_data: list[dict],
gap: int = GRID_GAP,
override_zone_geometries: Optional[dict[str, dict]] = None) -> dict:
"""Composition v0 layout preset → CSS grid 문자열.
IMP-09 PR 1 contract — every layout_css return path carries
matching-length heights_px (= grid rows R) and widths_px (= grid cols C),
plus ratios / width_ratios / dynamic_rows / dynamic_cols. The
horizontal-2 grid CSS strings (areas/cols/rows) remain byte-identical
to the legacy path.
Dynamic dispatch:
- topology="rows" -> _build_rows_dynamic (horizontal-2: row heights)
- topology="cols" -> _build_cols_dynamic (vertical-2: col widths)
- other topologies (single / T / inverted-T / side-T / 2x2) fall
through to _build_fr_default in PR 1; PR 2 enables the 2-D
dispatcher.
Step D-ext (사용자 lock 2026-05-08) — override_zone_geometries (zone_id ->
{x,y,w,h} slide-body 내부 0~1) 가 들어오면 그 비율로 layout_css 강제.
PR 1 lock: horizontal-2 / vertical-2 만 처리 (legacy inline preserve).
다른 preset 은 warn-and-fallthrough (PR 2 가 unified _override_to_grid_tracks
로 promote).
"""
preset = LAYOUT_PRESETS[layout_preset]
positions = preset["positions"]
topology = preset.get("topology")
# ── Step D-ext : user override 처리 ──
if override_zone_geometries:
if layout_preset == "horizontal-2":
# heights_px override — zone 의 h 비율로 SLIDE_BODY_HEIGHT 분배.
ratios = []
for pos in positions:
geom = override_zone_geometries.get(pos)
ratios.append(float(geom["h"]) if geom else 0.0)
total = sum(ratios)
if total > 0:
heights_px = [int(round(r / total * SLIDE_BODY_HEIGHT)) for r in ratios]
rows = " ".join(f"{h}px" for h in heights_px)
return {
"areas": preset["css_areas"],
"cols": preset["css_cols"],
"rows": rows,
"heights_px": heights_px,
"widths_px": [SLIDE_BODY_WIDTH],
"ratios": [round(r / total, 3) for r in ratios],
"width_ratios": [1.0],
"computation": "user_override_geometry",
"dynamic_rows": True,
"dynamic_cols": False,
"raw_zone_layout": {"override_applied": True, "source": override_zone_geometries},
}
elif layout_preset == "vertical-2":
# cols override — zone 의 w 비율로 fr 분배 (legacy: fr-string cols).
# PR 1 keeps fr-string cols for legacy preserve; widths_px is
# populated in pixels for _compute_per_zone_geometry length contract.
ratios = []
for pos in positions:
geom = override_zone_geometries.get(pos)
ratios.append(float(geom["w"]) if geom else 0.0)
total = sum(ratios)
if total > 0:
cols = " ".join(f"{round(r / total * 100, 2)}fr" for r in ratios)
normalized = [r / total for r in ratios]
widths_px = [
int(round(rr * (SLIDE_BODY_WIDTH - gap * (len(ratios) - 1))))
for rr in normalized
]
diff = (SLIDE_BODY_WIDTH - gap * (len(ratios) - 1)) - sum(widths_px)
if diff != 0 and widths_px:
widths_px[-1] += diff
return {
"areas": preset["css_areas"],
"cols": cols,
"rows": preset["css_rows"],
"heights_px": [SLIDE_BODY_HEIGHT],
"widths_px": widths_px,
"ratios": [1.0],
"width_ratios": [round(rr, 3) for rr in normalized],
"computation": "user_override_geometry",
"dynamic_rows": False,
"dynamic_cols": True,
"raw_zone_layout": {"override_applied": True, "source": override_zone_geometries},
}
elif topology in ("T", "inverted-T", "side-T-left", "side-T-right", "2x2"):
# IMP-09 PR 2 — 2-D override path (T / inverted-T / side-T / 2x2).
# Degenerate inputs (total_h == 0 or total_w == 0) fall back to
# _build_grid_dynamic_2d inside the helper.
return _override_to_grid_tracks(
preset, zones_data, override_zone_geometries, gap=gap
)
else:
# warn-and-fallthrough preserved for remaining presets (single).
# PR 3 territory.
print(
f" [override-warning] zone-geometry override 는 layout '{layout_preset}' 미지원 "
f"(현재 horizontal-2 / vertical-2 / 2-D presets 만). default layout_css 사용.",
file=sys.stderr,
)
# ── Dynamic branch — topology dispatch ──
if topology == "rows":
return _build_rows_dynamic(preset, zones_data, gap)
if topology == "cols":
return _build_cols_dynamic(preset, zones_data, gap)
if topology in ("T", "inverted-T", "side-T-left", "side-T-right", "2x2"):
return _build_grid_dynamic_2d(preset, zones_data, gap)
# PR 3 will dispatch single here.
return _build_fr_default(preset)
# ─── Abort ──────────────────────────────────────────────────────
def abort_with_error(run_dir: Path, section: MdxSection,
match: Optional[V4Match], stage: str, reason: str):
error_data = {
"section": {"id": section.section_id, "title": section.title},
"frame": {
"id": match.frame_id if match else None,
"number": match.frame_number if match else None,
"template_id": match.template_id if match else None,
},
"v4_label": match.label if match else None,
"phase_z_status": to_phase_z_status(match) if match else None,
"confidence": match.confidence if match else None,
"stage": stage,
"reason": reason,
}
run_dir.mkdir(parents=True, exist_ok=True)
err_path = run_dir / "error.json"
err_path.write_text(json.dumps(error_data, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"\n[Phase Z-2 MVP-1.5b] ABORT @ {stage}", file=sys.stderr)
print(f" section : {section.section_id}{section.title}", file=sys.stderr)
if match:
print(f" frame : {match.frame_id} ({match.template_id})", file=sys.stderr)
print(f" status : V4 label '{match.label}' → Phase Z '{to_phase_z_status(match)}'", file=sys.stderr)
print(f" reason : {reason}", file=sys.stderr)
print(f" error : {err_path}", file=sys.stderr)
sys.exit(1)
# ─── IMP-06 Step 6 zone-section assignment override (backend/CLI/composition only) ──
def _build_position_assignment_plan(
units: list,
positions: list[str],
override_section_assignments: Optional[dict[str, list[str]]],
sections_by_id: dict[str, "MdxSection"],
override_frames: Optional[dict[str, str]] = None,
v4: Optional[dict] = None,
) -> tuple[list[dict], dict]:
"""IMP-06 (#6 / Codex #6,#7 15-axis lock) — section-to-position assignment plan.
Pure helper invoked AFTER ``plan_composition()`` returns ``units`` and
AFTER ``override_layout`` has been applied so ``positions`` is the final
layout-preset position vocabulary.
Locked behavior :
- explicit override wins per position
- no section id appears in more than one final rendered position
- overlapping auto units are skipped WHOLE (no split, no cascade, no replan)
- template_id resolution ladder :
(1) ``override_frames`` exact ``unit_id`` (catalog validation downstream)
(2) exact existing auto unit reuse (same ``source_section_ids``)
(3) single-section override -> ``lookup_v4_match_with_fallback`` with
``raw_content=section.raw_content`` (direct executable only)
(4) multi-section ad-hoc override (no exact auto + no override-frame)
-> skipped_reason='ad_hoc_merged_no_template'
- additive trace : ``previous_source_section_ids`` (position-level history),
``skipped_collided_auto_units`` (collision-level), ``uncovered_section_ids``
(post-override coverage gap), ``v4_selector_trace`` (selector failure embed),
``skipped_reason`` for failed assignments
Returns ``(plan, summary)`` where :
- ``plan`` : list[dict] keyed by position with the per-position record
- ``summary`` : dict with applied/skipped counts + uncovered ids
NOTE : the helper does NOT mutate ``units`` and does NOT raise on validation
failures; caller is responsible for fail-fast validation of unknown zone ids
or unknown section ids before calling.
"""
overrides = override_section_assignments or {}
override_frames = override_frames or {}
# Section ids reserved by any explicit override.
overridden_section_ids: set[str] = set()
for _zid, sids in overrides.items():
overridden_section_ids.update(sids)
# Build position -> auto unit baseline. Auto plan uses sequential mapping of
# ``units`` over ``positions`` (the same order Step 6 of the pipeline uses).
auto_by_position: dict[str, object] = {}
for i, pos in enumerate(positions):
auto_by_position[pos] = units[i] if i < len(units) else None
# Reverse lookup : section_id -> auto unit (for collision detection).
auto_unit_by_section: dict[str, object] = {}
for u in units:
for sid in u.source_section_ids:
auto_unit_by_section[sid] = u
# Track auto units that get whole-skipped because of collision.
skipped_collided_unit_ids: set[str] = set()
plan: list[dict] = []
def _unit_id(sids: list[str]) -> str:
return "+".join(sids)
def _resolve_template_for_override(zone_id: str, sids: list[str]) -> tuple[
Optional[str], Optional[str], Optional[dict]
]:
"""template_id resolution ladder. Returns (template_id, skipped_reason, v4_selector_trace)."""
unit_id = _unit_id(sids)
# (1) explicit --override-frame for exact unit_id
if unit_id in override_frames:
return override_frames[unit_id], None, None
# (2) exact existing auto unit reuse
for u in units:
if list(u.source_section_ids) == list(sids):
return getattr(u, "frame_template_id", None) or getattr(u, "template_id", None), None, None
# (3) single-section selector
if len(sids) == 1:
sid = sids[0]
section = sections_by_id.get(sid)
if v4 is None or section is None:
return None, "no_v4_section", None
raw_content = getattr(section, "raw_content", None)
# IMP-08 B-3 : forward sub-section V4 aliases (decimal heading_number)
# when canonical ordinal id misses; safe for top-level sids (empty list).
alias_keys = list(getattr(section, "v4_alias_keys", []) or [])
match, trace = lookup_v4_match_with_fallback(
v4, sid, raw_content=raw_content, alias_keys=alias_keys
)
if match is None:
return None, "no_direct_render_template", trace
return match.template_id, None, trace
# (4) ad-hoc multi-section override without exact auto + without override-frame
return None, "ad_hoc_merged_no_template", None
# Iterate positions deterministically. Explicit overrides win.
for pos in positions:
if pos in overrides:
sids = overrides[pos]
auto_unit = auto_by_position.get(pos)
previous_source_section_ids = (
list(auto_unit.source_section_ids) if auto_unit is not None else []
)
# Sections that the previous auto unit at this position contained but
# the explicit override did not take = uncovered post-override.
sids_set = set(sids)
uncovered_from_previous = [
sid for sid in previous_source_section_ids if sid not in sids_set
]
template_id, skipped_reason, selector_trace = _resolve_template_for_override(pos, sids)
# IMP-06 Stage 4 (Codex #9 R1 + Claude #9 Catch L + Codex #10) — replaced_auto_unit
# populated only when the same position previously had an auto unit and that
# auto unit was different from the requested override. Documents "this auto
# unit was removed from this position to apply the override" as a distinct
# audit fact (vs skipped_collided_auto_units which is cross-position skip).
replaced_auto_unit = None
if auto_unit is not None and list(auto_unit.source_section_ids) != list(sids):
replaced_auto_unit = {
"unit_id": _unit_id(list(auto_unit.source_section_ids)),
"source_section_ids": list(auto_unit.source_section_ids),
"reason": "same_position_override_replacement",
}
plan.append({
"position": pos,
"assignment_source": "cli_override",
"unit_id": _unit_id(sids),
"source_section_ids": list(sids),
"template_id": template_id,
"previous_source_section_ids": previous_source_section_ids,
"section_assignment_override": {
"override_applied": True,
"override_source": "cli",
"zone_id": pos,
"requested_section_ids": list(sids),
},
"replaced_auto_unit": replaced_auto_unit,
"skipped_collided_auto_units": [],
"uncovered_section_ids": uncovered_from_previous,
"skipped_reason": skipped_reason,
"v4_selector_trace": selector_trace,
})
else:
# Auto-retain unless overlap with overridden sections.
auto_unit = auto_by_position.get(pos)
if auto_unit is None:
plan.append({
"position": pos,
"assignment_source": "empty",
"unit_id": None,
"source_section_ids": [],
"template_id": None,
"previous_source_section_ids": [],
"section_assignment_override": None,
"replaced_auto_unit": None,
"skipped_collided_auto_units": [],
"uncovered_section_ids": [],
"skipped_reason": "no_auto_unit_available",
"v4_selector_trace": None,
})
continue
overlap = [sid for sid in auto_unit.source_section_ids if sid in overridden_section_ids]
if overlap:
# Whole-skip the auto unit. Sections in the auto unit that were NOT taken
# by an override become uncovered.
unit_id_str = _unit_id(list(auto_unit.source_section_ids))
skipped_collided_unit_ids.add(unit_id_str)
uncovered = [
sid for sid in auto_unit.source_section_ids
if sid not in overridden_section_ids
]
plan.append({
"position": pos,
"assignment_source": "empty",
"unit_id": None,
"source_section_ids": [],
"template_id": None,
"previous_source_section_ids": list(auto_unit.source_section_ids),
"section_assignment_override": None,
"replaced_auto_unit": None,
"skipped_collided_auto_units": [{
"unit_id": unit_id_str,
"source_section_ids": list(auto_unit.source_section_ids),
"reason": "override_collision",
}],
"uncovered_section_ids": uncovered,
"skipped_reason": "override_collision",
"v4_selector_trace": None,
})
else:
plan.append({
"position": pos,
"assignment_source": "auto",
"unit_id": _unit_id(list(auto_unit.source_section_ids)),
"source_section_ids": list(auto_unit.source_section_ids),
"template_id": (
getattr(auto_unit, "frame_template_id", None)
or getattr(auto_unit, "template_id", None)
),
"previous_source_section_ids": list(auto_unit.source_section_ids),
"section_assignment_override": None,
"replaced_auto_unit": None,
"skipped_collided_auto_units": [],
"uncovered_section_ids": [],
"skipped_reason": None,
"v4_selector_trace": None,
})
# Summary aggregates.
applied = [p for p in plan if p["assignment_source"] == "cli_override"]
skipped_assignments = [p for p in plan if p["skipped_reason"] is not None]
all_uncovered: list[str] = []
for p in plan:
all_uncovered.extend(p.get("uncovered_section_ids", []))
summary = {
"section_assignment_overrides_applied": [
{"position": p["position"], "source_section_ids": p["source_section_ids"]}
for p in applied
],
"section_assignment_overrides_skipped": [
{"position": p["position"], "reason": p["skipped_reason"]}
for p in skipped_assignments
],
"applied_count": len(applied),
"skipped_count": len(skipped_assignments),
"uncovered_section_ids": all_uncovered,
"skipped_collided_auto_unit_ids": sorted(skipped_collided_unit_ids),
}
return plan, summary
# ─── Slot mapping (catalog-only dispatch) ──────────────────────
def _known_contract_ids() -> list[str]:
from phase_z2_mapper import load_frame_contracts
return list(load_frame_contracts().keys())
def map_mdx_to_slots(section: MdxSection, template_id: str) -> dict:
"""template_id → slot_payload via catalog contract only.
F13/F29/F16 등 모든 frame 의 slot 구조 / cardinality / role / payload builder 는
`templates/phase_z2/catalog/frame_contracts.yaml` 에 선언. legacy hand-coded
mapper / MAPPER_BY_TEMPLATE / COLOR_CLASS_BY_KEYWORD / 관련 helper 는
F13/F29/F16 transition (2026-04-29) 후 모두 제거.
template_id 가 catalog 에 없으면 ValueError — fallback 없음.
새 frame 추가 = catalog yaml 에 entry 추가 + (필요시) 새 builder/parser 등록.
"""
contract = get_contract(template_id)
if contract is None:
raise ValueError(
f"No frame_contracts entry for template_id='{template_id}'. "
f"Add an entry in templates/phase_z2/catalog/frame_contracts.yaml. "
f"Known contracts: {sorted(_known_contract_ids())}."
)
return map_with_contract(section, contract)
# ─── Asset copy ─────────────────────────────────────────────────
def copy_assets(template_id: str, run_dir: Path) -> Optional[Path]:
"""Frame asset (Figma) 폴더 복사 — frame_id 는 catalog contract 에서 도출.
contract 에 `frame_id` 없으면 (asset 없는 frame) None 반환.
이전엔 pipeline.py 에 TEMPLATE_TO_FRAME_ID Python dict 가 있었지만 catalog 로 이전 (2026-04-29).
"""
contract = get_contract(template_id)
frame_id = (contract or {}).get("frame_id")
if not frame_id:
return None
src = ASSETS_SOURCE_BASE / str(frame_id) / "assets"
if not src.exists():
return None
dst = run_dir / "assets" / template_id
dst.parent.mkdir(parents=True, exist_ok=True)
if dst.exists():
shutil.rmtree(dst)
shutil.copytree(src, dst)
return dst
# ─── Render (single slide + Type B) ────────────────────────────
def _read_token_css() -> str:
token_dir = PROJECT_ROOT / "templates" / "styles" / "tokens"
files = ["typography.css", "spacing.css", "colors.css"]
parts = []
for f in files:
path = token_dir / f
if path.exists():
parts.append(f"/* === {f} === */\n{path.read_text(encoding='utf-8')}")
return "\n\n".join(parts)
def _attempt_zone_ratio_retry(
*,
run_dir: Path,
out_path: Path,
slide_title: str,
slide_footer: Optional[str],
zones_data: list[dict],
debug_zones: list[dict],
layout_preset: str,
layout_css: dict,
overflow: dict,
fit_classification: dict,
router_decision: dict,
gap_px: int,
) -> dict:
"""A3 zone_ratio_retry orchestration.
locked rules :
- retry budget = 1
- slide-base / spacing / gap 고정
- target zone height 만 증가, sibling donor 에서 같은 양 차감
- donor 룰 strict (visual ok / capacity ok / slack > 0 / min_height 보존)
- (b) revert : redistribution fail 또는 rerender 후 visual fail 시 original final.html 그대로
Returns:
retry_trace dict (always returned, even when no retry attempted) with :
retry_attempted : bool
retry_action : 'zone_ratio_retry' or None
plan : phase_z2_retry.plan_zone_ratio_retry() 결과 (있을 때만)
rerender_attempted : bool
retry_passed : bool
retry_failure_reason : str or None
retried_candidate_path : str or None (rerender 한 경우 진단 artifact 경로)
post_retry_overflow : dict (retry_passed=True 일 때만)
post_retry_debug_zones : list (retry_passed=True 일 때만 — height_px 갱신본)
post_retry_layout_css : dict (retry_passed=True 일 때만)
"""
base_trace = {
"retry_attempted": False,
"retry_action": None,
"plan": None,
"rerender_attempted": False,
"retry_passed": False,
"retry_failure_reason": None,
"retried_candidate_path": None,
"safety_margin_px": DEFAULT_SAFETY_MARGIN_PX,
"policy": (
"A3 locked rules : retry budget=1, slide-base/spacing/gap fixed, "
"donor strict (sibling/visual ok/capacity ok/slack>0/min_height 보존), "
"(b) revert on redistribution fail or rerender visual fail."
),
}
# 1. retry attempt 자체가 적절한지 판단
if not router_decision.get("router_active"):
base_trace["retry_skipped_reason"] = "router_active=False (visual check passed — no overflow)"
return base_trace
proposed = router_decision.get("proposed_actions_summary") or []
if "zone_ratio_retry" not in proposed:
base_trace["retry_skipped_reason"] = (
f"zone_ratio_retry not in proposed_actions {proposed} (다른 action category)"
)
return base_trace
# IMP-09 PR 1 retry gate — row-axis retry is only valid for layouts whose
# row geometry is dynamic. 2-D / dynamic_cols layouts and fr_default sinks
# would either misapply row-only redistribution or produce a no-op trace.
if layout_css.get("dynamic_cols", False):
base_trace["retry_skipped_reason"] = (
"layout has dynamic_cols (2-D topology) — "
"row-axis retry not applicable to 2-D layouts (IMP-09 lock)"
)
return base_trace
if not layout_css.get("dynamic_rows", False):
base_trace["retry_skipped_reason"] = (
"layout is fr_default_from_preset (no dynamic geometry) — retry no-op"
)
return base_trace
# 2. plan
base_trace["retry_attempted"] = True
base_trace["retry_action"] = "zone_ratio_retry"
plan = plan_zone_ratio_retry(
debug_zones=debug_zones,
overflow=overflow,
fit_classification=fit_classification,
router_decision=router_decision,
safety_margin_px=DEFAULT_SAFETY_MARGIN_PX,
)
base_trace["plan"] = plan
if plan is None:
base_trace["retry_failure_reason"] = "plan_zone_ratio_retry returned None — no target classification matched zone_ratio_retry"
return base_trace
if not plan.get("feasible"):
# redistribution check 실패 → rerender 안 함, original final.html 그대로
base_trace["retry_failure_reason"] = plan.get("failure_reason")
print(
f" retry : zone_ratio_retry redistribution INFEASIBLE — "
f"target {plan['target_zone_position']} needs {plan['target_added_px']}px, "
f"{plan.get('failure_reason')}"
)
return base_trace
# 3. feasible — apply plan to layout_css, rerender to candidate path (NOT final.html yet)
new_layout_css = apply_retry_to_layout_css(
layout_css, plan, zones_data,
total_height=SLIDE_BODY_HEIGHT, gap_px=gap_px,
)
candidate_path = run_dir / "retried_candidate.html"
candidate_html = render_slide(
slide_title, slide_footer, zones_data, layout_preset, new_layout_css, gap_px=gap_px,
)
candidate_path.write_text(candidate_html, encoding="utf-8")
base_trace["rerender_attempted"] = True
base_trace["retried_candidate_path"] = str(candidate_path.relative_to(PROJECT_ROOT))
print(
f" retry : zone_ratio_retry attempted — target {plan['target_zone_position']} "
f"+{plan['target_added_px']}px (donor {plan['donor_zone_position']} -{plan['donor_reduced_px']}px) "
f"→ rerender to retried_candidate.html → visual check"
)
# 4. 후 visual check on candidate
candidate_overflow = run_overflow_check(candidate_path)
if candidate_overflow.get("passed", False):
# 성공 — final.html 을 candidate 로 promote
out_path.write_text(candidate_html, encoding="utf-8")
# debug_zones height_px / ratio 갱신 (post-retry 상태)
new_heights = new_layout_css["heights_px"]
new_ratios = new_layout_css["ratios"]
post_retry_debug_zones = []
for i, dz in enumerate(debug_zones):
new_dz = dict(dz)
new_dz["height_px"] = new_heights[i] if i < len(new_heights) else dz.get("height_px")
new_dz["ratio"] = new_ratios[i] if i < len(new_ratios) else dz.get("ratio")
new_dz["zone_height_post_retry"] = True
post_retry_debug_zones.append(new_dz)
base_trace["retry_passed"] = True
base_trace["post_retry_overflow"] = candidate_overflow
base_trace["post_retry_debug_zones"] = post_retry_debug_zones
base_trace["post_retry_layout_css"] = new_layout_css
print(f" retry : PASSED — final.html promoted to retried version")
return base_trace
# 5. rerender 후에도 visual fail → (b) revert : final.html 은 original 그대로 (이미 written)
base_trace["retry_passed"] = False
base_trace["retry_failure_reason"] = (
f"rerender visual_check failed: {candidate_overflow.get('fail_reasons')}. "
f"reverting to original final.html (retried_candidate.html stays as diagnostic only)."
)
base_trace["candidate_overflow_summary"] = {
"passed": False,
"fail_reasons": candidate_overflow.get("fail_reasons", []),
}
print(f" retry : FAILED — candidate visual_check 도 실패. revert to original. ({candidate_path.name} 은 diagnostic 으로 보존)")
return base_trace
# IMP-12 u8 — Step 17 salvage cascade orchestrator (deterministic, no normal-path AI).
# Plan/apply pairs: phase_z2_retry (u4/u5/u6). Routing: failure_router.route_retry_failure (u3).
# Pipeline wiring (cascade_inputs assembly + retry_trace merge) lands in u9.
_SALVAGE_FAIL_BY_ACTION = {
"cross_zone_redistribute": "cross_zone_redistribute_insufficient",
"glue_compression": "glue_absorption_insufficient",
"font_step_compression": "font_step_insufficient",
}
def _attempt_salvage_chain(
*, run_dir: Path, out_path: Path, slide_title: str, slide_footer: Optional[str],
zones_data: list[dict], layout_preset: str, layout_css: dict,
cascade_inputs: dict, initial_failure_type: str, gap_px: int,
) -> dict:
"""IMP-12 u8 — deterministic Step 17 salvage cascade (cross_zone → glue → font_step).
Per stage: plan → apply CSS → rerender → run_overflow_check. PASS promotes final.html;
cascade-exit (layout_adjust/frame_reselect/none) or all-fail preserves (b) revert.
Honors IMP-09 dynamic_cols / fr_default gate.
"""
trace = {"salvage_attempted": False, "salvage_passed": False, "salvage_steps": []}
if layout_css.get("dynamic_cols", False) or not layout_css.get("dynamic_rows", False):
trace["salvage_skipped_reason"] = "IMP-09 gate — dynamic_cols/no dynamic_rows; cascade no-op"
return trace
trace["salvage_attempted"] = True
failure_type = initial_failure_type
ci = cascade_inputs
for _ in range(len(_SALVAGE_FAIL_BY_ACTION)):
routing = route_retry_failure(failure_type) or {}
next_action = routing.get("next_proposed_action")
if next_action not in _SALVAGE_FAIL_BY_ACTION:
trace["salvage_terminal_action"] = next_action
trace["salvage_terminal_rationale"] = routing.get("rationale")
return trace
if next_action == "cross_zone_redistribute":
if ci.get("fit_analysis") is None:
plan = {"action": "cross_zone_redistribute", "feasible": False,
"failure_reason": "cascade_inputs.fit_analysis missing — cross_zone_redistribute requires fit_analysis."}
else:
plan = plan_cross_zone_redistribute(
fit_analysis=ci["fit_analysis"], containers=ci.get("containers") or {},
min_margin_px=ci.get("min_margin_px"))
apply_fn = apply_cross_zone_redistribute_css
elif next_action == "glue_compression":
plan = plan_glue_compression(
excess_px=float(ci.get("excess_px") or 0.0),
block_count=int(ci.get("block_count") or 0),
zone_position=str(ci.get("zone_position") or ""))
apply_fn = apply_glue_compression_css
else:
plan = plan_font_step_compression(
current_font_px=float(ci.get("current_font_px") or 0.0),
excess_after_glue_px=float(ci.get("excess_after_glue_px") or ci.get("excess_px") or 0.0),
available_lines=int(ci.get("available_lines") or 0),
chars_per_line=int(ci.get("chars_per_line") or 0),
zone_position=str(ci.get("zone_position") or ""))
apply_fn = apply_font_step_compression_css
css_override = apply_fn(plan) if (plan and plan.get("feasible")) else ""
candidate_path = run_dir / f"salvage_{next_action}_candidate.html"
candidate_html, candidate_overflow, passed = None, None, False
if css_override:
base = render_slide(slide_title, slide_footer, zones_data, layout_preset, layout_css, gap_px=gap_px)
style = f"<style>\n{css_override}\n</style>"
candidate_html = base.replace("</head>", f"{style}\n</head>", 1) if "</head>" in base else style + base
candidate_path.write_text(candidate_html, encoding="utf-8")
candidate_overflow = run_overflow_check(candidate_path)
passed = bool(candidate_overflow.get("passed", False))
step = {"action": next_action, "plan": plan, "passed": passed,
"css_override": css_override or None,
"candidate_path": str(candidate_path.relative_to(PROJECT_ROOT)) if css_override else None}
if passed:
out_path.write_text(candidate_html, encoding="utf-8")
step["post_salvage_overflow"] = candidate_overflow
trace["salvage_steps"].append(step)
trace["salvage_passed"] = True
return trace
step["failure_reason"] = (
(plan.get("failure_reason") if isinstance(plan, dict) else None)
or (candidate_overflow.get("fail_reasons") if candidate_overflow else None)
or "infeasible or no CSS emitted")
trace["salvage_steps"].append(step)
failure_type = _SALVAGE_FAIL_BY_ACTION[next_action]
return trace
def render_slide(slide_title: str, slide_footer: Optional[str],
zones_data: list[dict], layout_preset: str,
layout_css: dict, gap_px: int = GRID_GAP,
*, embedded_mode: str = "auto") -> str:
"""Single slide HTML — slide_base.html + 8-preset layout vocabulary.
layout_css = build_layout_css() 결과 — areas/cols/rows 문자열 + 동적 heights flag.
Template 은 layout_css.{areas,cols,rows} 를 grid CSS 에 직접 주입.
embedded_mode (IMP-14): "auto" | "embedded" | "standalone". Controls
slide_base.html body CSS contract. Default "auto" preserves backward-compat
with run_overflow_check standalone path and lets iframe consumers signal via
?embedded=1 or window.self!==window.top.
"""
if embedded_mode not in ("auto", "embedded", "standalone"):
raise ValueError(
f"render_slide: invalid embedded_mode={embedded_mode!r}; "
"expected one of 'auto', 'embedded', 'standalone'"
)
env = Environment(
loader=FileSystemLoader(str(TEMPLATE_DIR)),
autoescape=select_autoescape(["html"]),
)
for zone in zones_data:
# Stage 4 Part 2 (Codex #10 Catch N) — empty zone produced by section
# assignment override has no partial template; render an empty string so
# the slide_base zones loop preserves grid identity without TemplateNotFound.
if zone.get("template_id") == "__empty__":
zone["partial_html"] = ""
continue
partial = env.get_template(f"families/{zone['template_id']}.html")
zone["partial_html"] = partial.render(slot_payload=zone["slot_payload"])
base = env.get_template("slide_base.html")
return base.render(
slide_title=slide_title,
slide_footer=slide_footer,
zones=zones_data,
layout_preset=layout_preset,
layout_css=layout_css,
gap_px=gap_px,
token_css=_read_token_css(),
embedded_mode=embedded_mode,
)
# ─── Selenium check (single slide + per-zone) ──────────────────
def run_overflow_check(html_path: Path) -> dict:
"""Single slide + per-zone overflow + clipping check."""
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
from selenium.webdriver.chrome.service import Service
options = Options()
options.add_argument("--headless=new")
options.add_argument("--no-sandbox")
options.add_argument("--disable-dev-shm-usage")
options.add_argument("--window-size=1400,900")
chromedriver_candidates = [
PROJECT_ROOT / "chromedriver",
PROJECT_ROOT / "chromedriver.exe",
]
driver = None
last_err = None
for path in chromedriver_candidates:
if path.is_file():
try:
driver = webdriver.Chrome(service=Service(str(path)), options=options)
break
except Exception as e:
last_err = e
if driver is None:
try:
driver = webdriver.Chrome(options=options)
except Exception as e:
return {"passed": False, "error": f"selenium init failed: {last_err or e}"}
try:
driver.get(html_path.resolve().as_uri())
driver.set_window_size(1400, 900)
driver.implicitly_wait(1)
result = driver.execute_script(r"""
const measure = (el) => ({
clientWidth: el.clientWidth,
clientHeight: el.clientHeight,
scrollWidth: el.scrollWidth,
scrollHeight: el.scrollHeight,
excess_x: Math.max(0, el.scrollWidth - el.clientWidth),
excess_y: Math.max(0, el.scrollHeight - el.clientHeight),
overflowed: (el.scrollWidth > el.clientWidth + 5) ||
(el.scrollHeight > el.clientHeight + 5),
});
const slide = document.querySelector('.slide');
if (!slide) return { error: '.slide not found' };
const slideM = measure(slide);
slideM.size_correct = slide.clientWidth === 1280 && slide.clientHeight === 720;
// A-6 (IMP-01 #1) — slide-relative bbox base
const slideRect = slide.getBoundingClientRect();
const body = document.querySelector('.slide-body');
const bodyM = body ? measure(body) : null;
const zones = [];
const zone_geometries_px = [];
// IMP-15 실행-2 (issue #46) — element-identity dedup map for table_events.
// Map<Element, integer> keyed by DOM node reference (NOT class string) so that
// two wrappers sharing identical className resolve to distinct map entries.
// Populated alongside the existing per-zone clipped_inner scan below.
const clippedWrapperMap = new Map();
let clippedIdxCounter = 0;
slide.querySelectorAll('.zone').forEach((z) => {
const pos = z.getAttribute('data-zone-position') || 'unknown';
const tid = z.getAttribute('data-template-id') || '?';
const m = measure(z);
m.position = pos;
m.template_id = tid;
// A-6 (IMP-01 #1) — zone bbox in slide-relative px (additive trace, no layout side effect)
const zoneRect = z.getBoundingClientRect();
zone_geometries_px.push({
position: pos,
template_id: tid,
x: Math.round(zoneRect.left - slideRect.left),
y: Math.round(zoneRect.top - slideRect.top),
w: Math.round(zoneRect.width),
h: Math.round(zoneRect.height),
});
// 내부 clipping 검사 — frame-family root/cell 단위.
// tolerance / threshold 그대로. inner_content_signals 만 추가 보강 (detection 데이터 늘림).
const clipped = [];
z.querySelectorAll('[class*="f13b"], [class*="f29b"], [class*="f16b"]').forEach((el) => {
const dx = el.scrollWidth - el.clientWidth;
const dy = el.scrollHeight - el.clientHeight;
if (dx > 5 || dy > 5) {
// inner content signals — clipped cell 안에 *어떤 종류의 콘텐츠가 들어있는지* 보고.
// classifier 가 frame_internal_cell 만 봐서는 부족하니 inner 까지 본다.
const inner_signals = [];
if (el.querySelector('.transform-block, .transform-row, .transform-rows')) {
inner_signals.push('structural_unit');
}
if (el.querySelector('table')) {
inner_signals.push('tabular');
}
if (el.querySelector('.text-line')) {
inner_signals.push('text_flow');
}
clipped.push({
class_name: el.className,
inner_content_signals: inner_signals,
excess_x: Math.max(0, dx),
excess_y: Math.max(0, dy),
clientWidth: el.clientWidth,
clientHeight: el.clientHeight,
scrollWidth: el.scrollWidth,
scrollHeight: el.scrollHeight,
});
// IMP-15 실행-2 (issue #46) — element-identity registration.
// Key by DOM node `el`, NOT className: two wrappers with identical
// class string still hash to distinct Map entries.
if (!clippedWrapperMap.has(el)) {
clippedWrapperMap.set(el, clippedIdxCounter);
clippedIdxCounter++;
}
}
});
m.clipped_inner = clipped;
zones.push(m);
});
// B5 v0 — frame_slot_metrics (per-cell measurement of [data-frame-slot-id])
// 현재 F29 partial 만 marker 보유 (process_column / product_column × 3 cells = 6 entries 기대).
// 다른 frame (F13 / F16) 은 marker 미적용 → entry 0 — 정상.
const frame_slot_metrics = [];
slide.querySelectorAll('[data-frame-slot-id]').forEach((cell) => {
const slotId = cell.getAttribute('data-frame-slot-id');
const m2 = measure(cell);
const parentZone = cell.closest('.zone');
const zonePos = parentZone
? (parentZone.getAttribute('data-zone-position') || 'unknown')
: 'unknown';
const zoneTid = parentZone
? (parentZone.getAttribute('data-template-id') || '?')
: '?';
frame_slot_metrics.push({
zone_position: zonePos,
zone_template_id: zoneTid,
frame_slot_id: slotId,
class_name: cell.className,
clientWidth: m2.clientWidth,
clientHeight: m2.clientHeight,
scrollWidth: m2.scrollWidth,
scrollHeight: m2.scrollHeight,
excess_x: m2.excess_x,
excess_y: m2.excess_y,
overflowed: m2.overflowed,
});
});
// IMP-15 실행-1 (issue #45) — image_events[] for image_aspect_mismatch detection.
// 하나의 entry per <img> under .slide. natural vs rendered aspect 비교.
// zone_position : closest('.zone') data-zone-position. 없으면 literal "unknown".
const image_events = [];
slide.querySelectorAll('img').forEach((img) => {
const parentZone = img.closest('.zone');
const zonePos = parentZone
? (parentZone.getAttribute('data-zone-position') || 'unknown')
: 'unknown';
const zoneTid = parentZone
? (parentZone.getAttribute('data-template-id') || '?')
: '?';
const imgRect = img.getBoundingClientRect();
const rendered_w = imgRect.width;
const rendered_h = imgRect.height;
const natural_w = img.naturalWidth;
const natural_h = img.naturalHeight;
const natural_ratio = (natural_w > 0 && natural_h > 0)
? (natural_w / natural_h)
: null;
const rendered_ratio = (rendered_w > 0 && rendered_h > 0)
? (rendered_w / rendered_h)
: null;
const delta = (natural_ratio !== null && rendered_ratio !== null)
? (rendered_ratio - natural_ratio)
: null;
image_events.push({
src: img.getAttribute('src') || '',
zone_position: zonePos,
zone_template_id: zoneTid,
natural_w: natural_w,
natural_h: natural_h,
rendered_w: Math.round(rendered_w),
rendered_h: Math.round(rendered_h),
natural_ratio: natural_ratio,
rendered_ratio: rendered_ratio,
delta: delta,
bbox: {
x: Math.round(imgRect.left - slideRect.left),
y: Math.round(imgRect.top - slideRect.top),
w: Math.round(rendered_w),
h: Math.round(rendered_h),
},
});
});
// IMP-15 실행-2 (issue #46) — table_events[] for table_self_overflow detection.
// One entry per <table> under .slide. wrapper_clipped_index is the integer index
// (from clippedWrapperMap) of the nearest ancestor that is itself in the clipped
// wrapper set, or null. Element-identity walk (NOT className) so that two same-class
// wrappers (W1 clipped, W2 not) resolve independently for any contained <table>.
const table_events = [];
slide.querySelectorAll('table').forEach((tbl) => {
const parentZone = tbl.closest('.zone');
const zonePos = parentZone
? (parentZone.getAttribute('data-zone-position') || 'unknown')
: 'unknown';
const zoneTid = parentZone
? (parentZone.getAttribute('data-template-id') || '?')
: '?';
let wrapper_clipped_index = null;
let node = tbl.parentElement;
while (node && node !== slide) {
if (clippedWrapperMap.has(node)) {
wrapper_clipped_index = clippedWrapperMap.get(node);
break;
}
node = node.parentElement;
}
const tblRect = tbl.getBoundingClientRect();
const dx = tbl.scrollWidth - tbl.clientWidth;
const dy = tbl.scrollHeight - tbl.clientHeight;
table_events.push({
zone_position: zonePos,
zone_template_id: zoneTid,
clientWidth: tbl.clientWidth,
clientHeight: tbl.clientHeight,
scrollWidth: tbl.scrollWidth,
scrollHeight: tbl.scrollHeight,
excess_x: Math.max(0, dx),
excess_y: Math.max(0, dy),
wrapper_clipped_index: wrapper_clipped_index,
bbox: {
x: Math.round(tblRect.left - slideRect.left),
y: Math.round(tblRect.top - slideRect.top),
w: Math.round(tblRect.width),
h: Math.round(tblRect.height),
},
});
});
return { slide: slideM, slide_body: bodyM, zones, frame_slot_metrics, zone_geometries_px, image_events, table_events };
""")
screenshot_path = html_path.parent / "preview.png"
try:
driver.save_screenshot(str(screenshot_path))
result["screenshot"] = str(screenshot_path.relative_to(PROJECT_ROOT))
except Exception as e:
result["screenshot_error"] = str(e)
finally:
driver.quit()
if "error" in result:
return {"passed": False, **result}
fail_reasons = []
if not result["slide"]["size_correct"]:
fail_reasons.append(
f"slide size != 1280x720 (got {result['slide']['clientWidth']}x{result['slide']['clientHeight']})"
)
if result["slide"]["overflowed"]:
fail_reasons.append(
f"slide overflowed by {result['slide']['excess_y']}px (vert) / {result['slide']['excess_x']}px (horiz)"
)
if result.get("slide_body") and result["slide_body"]["overflowed"]:
fail_reasons.append(
f"slide-body overflowed by {result['slide_body']['excess_y']}px (vert)"
)
for z in result["zones"]:
if z["overflowed"]:
fail_reasons.append(
f"zone--{z['position']} ({z['template_id']}) overflowed by {z['excess_y']}px (vert) / {z['excess_x']}px (horiz)"
)
for c in z.get("clipped_inner", []):
fail_reasons.append(
f"zone--{z['position']}: inner clipped .{c['class_name']}"
f"excess {c['excess_y']}px vert / {c['excess_x']}px horiz "
f"(content {c['scrollHeight']} vs container {c['clientHeight']})"
)
# IMP-15 실행-1 (issue #45) — image_aspect_mismatch aggregation.
# |natural_ratio - rendered_ratio| > IMAGE_ASPECT_DELTA_TOL ⇒ fail_reason append.
# Entries with null ratio (image not loaded / natural dims = 0) are skipped (no false positive).
for ev in result.get("image_events", []):
delta = ev.get("delta")
if delta is None:
continue
if abs(delta) > IMAGE_ASPECT_DELTA_TOL:
n_ratio = ev.get("natural_ratio")
r_ratio = ev.get("rendered_ratio")
src = ev.get("src", "")
pos = ev.get("zone_position", "unknown")
tid = ev.get("zone_template_id", "?")
fail_reasons.append(
f"image aspect mismatch in zone--{pos}: "
f"natural={n_ratio:.3f} rendered={r_ratio:.3f} delta={delta:+.3f} "
f"(template={tid}, tol={IMAGE_ASPECT_DELTA_TOL}, src={src})"
)
# IMP-15 실행-2 (issue #46) — table_self_overflow aggregation.
# Emit fail_reason only when (excess_x>TOL OR excess_y>TOL) AND wrapper_clipped_index is None.
# The clipped-wrapper case is already accounted for by the clipped_inner fail_reason above;
# element-identity dedup (clippedWrapperMap keyed by DOM node ref, NOT className) prevents
# double-counting and—critically—prevents two same-class wrappers from masking each other.
for ev in result.get("table_events", []):
if ev.get("wrapper_clipped_index") is not None:
continue
excess_x = ev.get("excess_x", 0) or 0
excess_y = ev.get("excess_y", 0) or 0
if excess_x > TABLE_SCROLL_TOL_PX or excess_y > TABLE_SCROLL_TOL_PX:
pos = ev.get("zone_position", "unknown")
tid = ev.get("zone_template_id", "?")
fail_reasons.append(
f"table self-overflow in zone--{pos}: "
f"excess {excess_y}px vert / {excess_x}px horiz "
f"(content {ev.get('scrollWidth')}x{ev.get('scrollHeight')} vs "
f"container {ev.get('clientWidth')}x{ev.get('clientHeight')}, "
f"template={tid}, tol={TABLE_SCROLL_TOL_PX})"
)
result["passed"] = len(fail_reasons) == 0
result["fail_reasons"] = fail_reasons
return result
def write_overflow_error(run_dir: Path, overflow: dict) -> Path:
error_data = {
"stage": "visual_runtime_check",
"reason": "Visual runtime contract 위반 — slide / slide-body / zone overflow / clipping.",
"fail_reasons": overflow.get("fail_reasons", []),
"details": overflow,
}
err_path = run_dir / "error.json"
err_path.write_text(json.dumps(error_data, ensure_ascii=False, indent=2), encoding="utf-8")
return err_path
# ─── Debug.json (single slide + zones[]) ───────────────────────
def compute_slide_status(sections: list[MdxSection],
units: list[CompositionUnit],
comp_debug: dict,
overflow: dict,
adapter_needed_units: Optional[list[dict]] = None,
debug_zones: Optional[list[dict]] = None) -> dict:
"""Slide 산출물의 정확한 상태 계산 — 자동 파이프라인 결과 보고.
축 :
- rendered : final.html 이 디스크에 쓰였는가
- visual_check_passed : Selenium per-zone overflow / clipping 통과 여부
- full_mdx_coverage : aligned 된 모든 section_id 가 어떤 selected unit 에 의해 covered
- adapter_needed_count : mapper FitError 로 자동 렌더 못 한 unit 수 (별 review 개념 X — 자동 실패 보고)
- content_truncated_count : builder 가 truncate 한 zone 수 (informational)
- provisional_first_render_count : IMP-30 first-render invariant 로 합성된 unit 수
(u1 V4Match synthesis / u3 last-resort fill /
u4 empty-shell — needs user/AI adaptation 신호)
overall enum :
PASS — visual OK + full coverage + adapter_needed=0
RENDERED_WITH_VISUAL_REGRESSION — full coverage 이지만 visual fail
PARTIAL_COVERAGE — 일부 section 필터됨, 렌더된 부분만 visual OK
PARTIAL_COVERAGE_WITH_VISUAL_REGRESSION — 둘 다
(adapter_needed > 0 시 status note 추가, overall 은 위 enum 사용)
(IMP-30 u6 : provisional_first_render_count 도 qualifier 일 뿐, overall enum 변경 X.
Stage 1 Q3 + Codex #10 D4 lock.)
"""
aligned_ids = [s.section_id for s in sections]
covered = set()
for u in units:
covered.update(u.source_section_ids)
filtered_ids = sorted(set(aligned_ids) - covered)
full_coverage = len(filtered_ids) == 0
visual_passed = bool(overflow.get("passed", False))
adapter_needed_units = list(adapter_needed_units or [])
content_truncated = []
fallback_selections = []
for z in (debug_zones or []):
if z.get("fallback_used"):
fallback_selections.append({
"position": z["position"],
"source_section_ids": z["source_section_ids"],
"template_id": z["v4_template_id"],
"selected_v4_rank": z.get("v4_selected_rank"),
"selection_path": z.get("selection_path"),
"fallback_reason": z.get("fallback_reason"),
})
tc = z.get("content_truncated_count")
if tc:
content_truncated.append({
"position": z["position"],
"source_section_ids": z["source_section_ids"],
"template_id": z["v4_template_id"],
"truncated_count": tc,
})
# 필터된 section 의 사유 (auto pipeline 결정 트레이스 — review 개념 X)
filtered_section_reasons = []
for c in comp_debug.get("candidates_summary", []):
if c.get("selection_state") == "selected":
continue
cand_ids = c.get("source_section_ids", [])
if any(sid in filtered_ids for sid in cand_ids):
filtered_section_reasons.append({
"section_ids": cand_ids,
"merge_type": c.get("merge_type"),
"template_id": c.get("template_id"),
"v4_label": c.get("label"),
"phase_z_status": c.get("phase_z_status"),
"score": c.get("score"),
"selection_state": c.get("selection_state"), # filtered_status / filtered_weak / filtered_lost
"filter_reasons": c.get("filter_reasons", []),
})
# IMP-06 blocker-fix (Codex #10 Catch O schema + Codex #16 coverage invariant) —
# surface override-uncovered sections as additive list entries in
# `filtered_section_reasons` and ensure `filtered_section_ids` includes them
# so coverage does not silently miss sections that were dropped by an explicit
# zone-section override.
v4_fb_summary = comp_debug.get("v4_fallback_summary", {}) or {}
section_assignment_summary = comp_debug.get("section_assignment_summary") or {}
section_assignment_uncovered_ids: list[str] = list(
section_assignment_summary.get("uncovered_section_ids") or []
)
if section_assignment_uncovered_ids:
# Codex #16 invariant : final filtered_section_ids must contain the
# override-uncovered ids even if they were originally "covered" by the
# pre-override auto plan. full_coverage must be re-evaluated too so
# Step 20 `overall` enum reflects the post-override reality.
for sid in section_assignment_uncovered_ids:
if sid not in filtered_ids:
filtered_ids.append(sid)
filtered_ids = sorted(set(filtered_ids))
full_coverage = len(filtered_ids) == 0
# Append a separate list entry per override-uncovered section so existing
# readers of filtered_section_reasons (list-shaped) keep working.
plan_by_position = {
(p.get("position") or ""): p
for p in (comp_debug.get("section_assignment_plan") or [])
}
for sid in section_assignment_uncovered_ids:
# Find the position whose plan entry recorded this uncovered id.
source_position = None
for pos, entry in plan_by_position.items():
if sid in (entry.get("uncovered_section_ids") or []):
source_position = pos
break
filtered_section_reasons.append({
"section_ids": [sid],
"merge_type": None,
"template_id": None,
"v4_label": None,
"phase_z_status": None,
"score": None,
"selection_state": "section_assignment_override_uncovered",
"filter_reasons": ["section_assignment_override_uncovered"],
"source": "section_assignment_override",
"position": source_position,
})
if full_coverage and visual_passed:
overall = "PASS"
elif full_coverage and not visual_passed:
overall = "RENDERED_WITH_VISUAL_REGRESSION"
elif not full_coverage and visual_passed:
overall = "PARTIAL_COVERAGE"
else:
overall = "PARTIAL_COVERAGE_WITH_VISUAL_REGRESSION"
# IMP-05 L3 (Codex #10 D4 / #17 idea F / Claude #21 idea J) — Step 20 qualifier fields.
# Additive only — top-level overall enum unchanged. Defensive defaults so non-fallback
# paths (empty v4_fallback_summary) do not crash and report 0 / [] cleanly.
_v4_fb_summary = comp_debug.get("v4_fallback_summary", {}) or {}
_fallback_selection_count = _v4_fb_summary.get("fallback_selection_count", 0)
_selection_paths = _v4_fb_summary.get("selection_paths", [])
# IMP-30 u6 — Step 20 additive qualifier fields for the first-render invariant.
# provisional_first_render_count = number of selected units whose .provisional
# flag is True (set by u1 V4Match synthesis → u2 CompositionUnit propagation,
# u3 last-resort fill, or u4 empty-shell synthesis). The list mirrors the shape
# of fallback_selections / adapter_needed_units for symmetry. Top-level overall
# enum stays unchanged per IMP-05 Codex #10 D4 + Stage 1 Q3 decision: this
# signal is a qualifier, not a new failure class. Defensive getattr keeps the
# function safe when units come from legacy code paths predating u2.
provisional_first_render_units: list[dict] = []
for u in units:
if not getattr(u, "provisional", False):
continue
provisional_first_render_units.append({
"source_section_ids": list(getattr(u, "source_section_ids", []) or []),
"phase_z_status": getattr(u, "phase_z_status", None),
"frame_template_id": getattr(u, "frame_template_id", None),
"frame_id": getattr(u, "frame_id", None),
"label": getattr(u, "label", None),
"selection_path": getattr(u, "selection_path", None),
"fallback_reason": getattr(u, "fallback_reason", None),
"v4_rank": getattr(u, "v4_rank", None),
})
return {
"rendered": True,
"visual_check_passed": visual_passed,
"full_mdx_coverage": full_coverage,
"aligned_section_ids": aligned_ids,
"covered_section_ids": sorted(covered),
"filtered_section_ids": filtered_ids,
"filtered_section_reasons": filtered_section_reasons,
"selection_path": "fallback_used" if fallback_selections else "rank_1",
"fallback_used": bool(fallback_selections),
"fallback_selections": fallback_selections,
# IMP-05 L3 qualifier fields — grouped near existing fallback fields for readability.
"fallback_selection_count": _fallback_selection_count,
"selection_paths": _selection_paths,
"visual_fail_reasons": list(overflow.get("fail_reasons") or []),
"adapter_needed_count": len(adapter_needed_units),
"adapter_needed_units": adapter_needed_units,
"content_truncated_count": len(content_truncated),
"content_truncated_units": content_truncated,
# IMP-30 u6 — additive provisional qualifiers (overall enum unchanged).
"provisional_first_render_count": len(provisional_first_render_units),
"provisional_first_render_units": provisional_first_render_units,
"overall": overall,
"note": (
"자동 파이프라인 결과 보고. review/UI 개념 X. final.html 파일명 != PASS 의미. "
"overall == PASS 는 visual OK + full coverage + adapter_needed=0 일 때만. "
"adapter_needed_count > 0 = mapper 가 contract 와 안 맞아 자동 렌더 못 한 zone 존재. "
"content_truncated_count > 0 = builder 가 truncate 한 zone 존재 (rendered 됐지만 일부 콘텐츠 손실). "
"provisional_first_render_count > 0 = IMP-30 first-render invariant 가 작동한 unit 존재 "
"(empty_shell / chain_exhausted_provisional / 등 — needs user/AI adaptation)."
),
}
# ─── Per-step artifact write (locked schema) ────────────────────
# 모든 step JSON 공통 필드: step_num, step_name, step_status,
# pipeline_path_connected, input, output, note, data
# 사용자 lock — 한 슬라이드 결과물 고치지 말고 시스템 layer 박기 (오답노트 #2)
def _write_step_artifact(
run_dir: Path,
step_num: int,
name: str,
data,
*,
step_status: str = "done",
pipeline_path_connected: bool = True,
inputs: Optional[list[str]] = None,
outputs: Optional[list[str]] = None,
note: Optional[str] = None,
) -> Path:
"""Write per-step JSON artifact to {run_dir}/steps/step{NN}_{name}.json.
Locked schema (사용자 직설):
step_num, step_name, step_status, pipeline_path_connected,
input, output, note, data.
Status values: 'done' / 'partial' / 'trace-only' / 'future' / 'failed'.
"""
steps_dir = run_dir / "steps"
steps_dir.mkdir(exist_ok=True)
fname = f"step{step_num:02d}_{name}.json"
fpath = steps_dir / fname
payload = {
"step_num": step_num,
"step_name": name,
"step_status": step_status,
"pipeline_path_connected": pipeline_path_connected,
"input": inputs or [],
"output": outputs or [fname],
"note": note,
"data": data,
}
fpath.write_text(
json.dumps(payload, ensure_ascii=False, indent=2, default=str),
encoding="utf-8",
)
return fpath
def _write_step_html(
run_dir: Path,
step_num: int,
name: str,
title: str,
body_html: str,
*,
step_status: str = "done",
inputs: Optional[list[str]] = None,
outputs: Optional[list[str]] = None,
) -> Path:
"""Write per-step HTML artifact with locked header (input/output/status).
HTML 산출물 = 사용자가 시각으로 판단해야 하는 step (7/8/9/13/20).
"""
steps_dir = run_dir / "steps"
steps_dir.mkdir(exist_ok=True)
fname = f"step{step_num:02d}_{name}.html"
fpath = steps_dir / fname
inputs_lines = "\n".join(f" - {i}" for i in (inputs or []))
outputs_lines = "\n".join(f" - {o}" for o in (outputs or [fname]))
status_class = (
"pass" if step_status == "done"
else "fail" if step_status == "failed"
else "partial"
)
inputs_li = "".join(f"<li><code>{i}</code></li>" for i in (inputs or []))
outputs_li = "".join(f"<li><code>{o}</code></li>" for o in (outputs or [fname]))
full_html = f"""<!--
Step {step_num:02d}: {title}
Status: {step_status}
Input:
{inputs_lines}
Output:
{outputs_lines}
-->
<!DOCTYPE html>
<html lang="ko"><head>
<meta charset="UTF-8">
<title>Step {step_num:02d}{title}</title>
<style>
body {{ font-family: 'Pretendard', 'Noto Sans KR', sans-serif; padding: 24px; max-width: 1280px; margin: 0 auto; color: #1e293b; }}
h1 {{ border-bottom: 2px solid #333; padding-bottom: 8px; margin-top: 0; }}
h2 {{ margin-top: 32px; }}
.meta {{ background: #f8fafc; padding: 16px; border-left: 4px solid #2563eb; margin-bottom: 24px; }}
.meta ul {{ margin: 4px 0; padding-left: 20px; }}
.meta li {{ margin-bottom: 2px; }}
.pass {{ color: #16a34a; font-weight: 700; }}
.fail {{ color: #dc2626; font-weight: 700; }}
.partial {{ color: #ca8a04; font-weight: 700; }}
table {{ border-collapse: collapse; width: 100%; margin-top: 12px; }}
th, td {{ border: 1px solid #cbd5e1; padding: 8px 12px; text-align: left; vertical-align: top; }}
th {{ background: #f1f5f9; }}
code {{ background: #f1f5f9; padding: 2px 6px; border-radius: 3px; font-size: 0.9em; }}
.grid-preview {{ border: 2px solid #cbd5e1; padding: 8px; margin-top: 12px; }}
.zone-box {{ background: #e2e8f0; border: 1px solid #94a3b8; padding: 12px; margin: 4px 0; border-radius: 4px; }}
.candidate-card {{ border: 1px solid #cbd5e1; padding: 12px; margin: 8px 0; border-radius: 4px; }}
.candidate-rank1 {{ border-left: 4px solid #16a34a; background: #f0fdf4; }}
</style>
</head><body>
<h1>Step {step_num:02d}{title}</h1>
<div class="meta">
<strong>Status:</strong> <span class="{status_class}">{step_status}</span><br>
<strong>Input:</strong>
<ul>{inputs_li}</ul>
<strong>Output:</strong>
<ul>{outputs_li}</ul>
</div>
{body_html}
</body>
</html>"""
fpath.write_text(full_html, encoding="utf-8")
return fpath
def write_debug_json(run_dir: Path, layout_preset: str,
debug_zones: list[dict],
layout_css: dict,
visual_runtime_check: Optional[dict] = None,
composition_debug: Optional[dict] = None,
slide_status: Optional[dict] = None,
fit_classification: Optional[dict] = None,
router_decision: Optional[dict] = None,
retry_trace: Optional[dict] = None) -> Path:
debug = {
"v4_source": str(V4_RESULT_PATH.relative_to(PROJECT_ROOT)),
"v4_label_to_phase_z_status": V4_LABEL_TO_PHASE_Z_STATUS,
"mvp1_allowed_statuses": sorted(MVP1_ALLOWED_STATUSES),
"mode": "composition_v0_layout_8preset",
"mode_note": (
"MVP-1.5b w/ composition planner v0 — sections → candidates (separate / "
"parent_merged) → score → greedy select → 8-preset layout vocabulary "
"(single / horizontal-2 / vertical-2 / top-1-bottom-2 / top-2-bottom-1 / "
"left-1-right-2 / left-2-right-1 / grid-2x2). v0 layout = count-based; "
"v1 axes (cardinality_fit / hierarchy_coherence / density_score) 추후."
),
"layout_preset": layout_preset,
"layout_css": layout_css,
"slide_status": slide_status,
"fit_classification": fit_classification,
"router_decision": router_decision,
"retry_trace": retry_trace,
"composition_planner_debug": composition_debug,
"zones": debug_zones,
"visual_runtime_check": visual_runtime_check,
# A-6 (IMP-01 #1) — additive top-level zone bbox trace (slide-relative px)
"zone_geometries_px": (visual_runtime_check or {}).get("zone_geometries_px", []),
# IMP-15 실행-4 (issue #48) — additive top-level Step 14 event streams
"image_events": (visual_runtime_check or {}).get("image_events", []),
"table_events": (visual_runtime_check or {}).get("table_events", []),
}
debug_path = run_dir / "debug.json"
debug_path.write_text(json.dumps(debug, ensure_ascii=False, indent=2), encoding="utf-8")
return debug_path
# ─── Step 9 application-plan helpers (IMP-32 u1) ───────────────
def _application_candidates_for_unit(unit) -> list[dict]:
"""Step 9 (IMP-32 u1) — application candidate dicts from unit.v4_candidates.
Pure extraction of inline block at src/phase_z2_pipeline.py:4487-4501.
Behavior preserved: key set/order, APPLICATION_MODE_BY_V4_LABEL lookup,
required_changes placeholder = [] (v0 = trace-only).
"""
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,
})
return app_candidates
def _v4_all_judgments_for_unit(v4_all_for_unit) -> list[dict]:
"""Step 9 (IMP-32 u2) — V4 all-judgment dicts (reject 포함) for a unit.
Pure extraction of inline block at src/phase_z2_pipeline.py:4529-4545
(post-u1 line numbers). IMP-11 D-2 markers preserved in this helper:
single `_contract = get_contract(c.template_id)` bind, `catalog_registered`
boolean, and `min_height_px` chain `(_contract or {}).get("visual_hints", {}).get("min_height_px")`.
Key set/order unchanged: template_id, frame_id, frame_number, v4_rank,
confidence, label, catalog_registered, min_height_px.
"""
# IMP-11 D-2 (u1) — per-candidate min_height_px source = catalog
# frame_contracts[template_id].visual_hints.min_height_px (logical 1280×720 px).
# None when contract unregistered (frontend tolerates undefined).
# Single get_contract lookup binds both catalog_registered and min_height_px.
v4_all_judgments_list = []
for c in v4_all_for_unit:
_contract = get_contract(c.template_id)
v4_all_judgments_list.append({
"template_id": c.template_id,
"frame_id": c.frame_id,
"frame_number": c.frame_number,
"v4_rank": c.v4_rank,
"confidence": c.confidence,
"label": c.label,
"catalog_registered": _contract is not None,
"min_height_px": (_contract or {}).get("visual_hints", {}).get("min_height_px"),
})
return v4_all_judgments_list
def _build_application_plan_unit(
unit,
zone_plan,
selection_trace,
plan_record,
v4_all_for_unit,
layout_preset,
layout_candidates_list,
) -> dict:
"""Step 9 (IMP-32 u3) — per-unit application_plan dict assembly.
Pure extraction of the inline `application_plan_units.append({...})` block
currently at src/phase_z2_pipeline.py:4577-4623 (post-u1/u2 line numbers).
Byte-identical output (key set + key order + value identity) when called
with the same per-unit inputs:
- unit : Step 6 unit (source_section_ids, v4_candidates,
v4_rank, selection_path, fallback_reason,
frame_template_id).
- zone_plan : Step 8 per-unit zone_plan dict (region_layout_
candidates, display_strategy_candidates).
- selection_trace : v4_fallback_traces[unit.source_section_ids[0]]
(candidates list for candidate_evidence /
fallback_chain compat alias).
- plan_record : plan_record_by_unit_id[id(unit)] or None
(IMP-06 plan-aware additive fields).
- v4_all_for_unit : lookup_v4_all_judgments(...) result (Step 7-A
axis trace — reject 포함 모든 V4 후보).
- layout_preset : Step 7 preset name (e.g., "Type A").
- layout_candidates_list : Step 7 candidate list.
Per-index/per-id lookups (zone_region_plans[i], v4_fallback_traces.get(...),
plan_record_by_unit_id.get(id(unit)), section_alias_by_id, lookup_v4_all_
judgments(...)) stay at the call-site (u4).
Invariants preserved:
- candidate_evidence = selection_trace.get("candidates", []) — primary field.
- fallback_chain = same list — compat alias for pre-IMP-05 readers.
- v4_candidates list comprehension fields + order unchanged.
- IMP-06 additive plan fields (position / assignment_source / section_
assignment_override / replaced_auto_unit / skipped_collided_auto_units /
skipped_reason) — None / False / [] when no override CLI used.
"""
unit_id = "+".join(unit.source_section_ids)
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.frame_template_id if has_v4 else None
# IMP-06 blocker-fix (Codex #13 Blocker 3 / #16) — plan-aware additive
# fields. additive = pre-IMP-06 readers (no override CLI used) see
# position=None / assignment_source=None / section_assignment_override
# =False / replaced_auto_unit=None / skipped_collided_auto_units=[] /
# skipped_reason=None — i.e. byte-identical absent overrides.
plan_position = plan_record.get("position") if plan_record else None
plan_assignment_source = plan_record.get("assignment_source") if plan_record else None
plan_section_override = bool(plan_record.get("section_assignment_override")) if plan_record else False
plan_replaced_auto = plan_record.get("replaced_auto_unit") if plan_record else None
plan_skipped_collided = list(plan_record.get("skipped_collided_auto_units") or []) if plan_record else []
plan_skipped_reason = plan_record.get("skipped_reason") if plan_record else None
app_candidates = _application_candidates_for_unit(unit)
v4_all_judgments_list = _v4_all_judgments_for_unit(v4_all_for_unit)
return {
"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,
"selected_v4_rank": unit.v4_rank,
"selection_path": unit.selection_path,
"fallback_used": bool(unit.selection_path and "fallback" in unit.selection_path),
"fallback_reason": unit.fallback_reason,
# IMP-05 L2 (Codex #10 D4 / #16 idea A) — Step 9 per-unit candidate evidence.
# candidate_evidence is the primary field for future frontend / AI consumers.
# fallback_chain is kept as a compat alias for any pre-IMP-05 reader.
"candidate_evidence": selection_trace.get("candidates", []),
"fallback_chain": selection_trace.get("candidates", []), # compat alias; prefer candidate_evidence
"v4_candidates": [
{
"template_id": c.template_id,
"frame_id": c.frame_id,
"frame_number": c.frame_number,
"v4_rank": c.v4_rank,
"confidence": c.confidence,
"label": c.label,
}
for c in unit.v4_candidates
],
# Step 7-A axis 보강 (사용자 lock 2026-05-08) — frontend UI 가 reject
# 포함 모든 V4 후보를 시각 차별 (회색) 로 보여줄 수 있도록 trace.
# length = 0~32. label 별 count : v4_candidates 는 non-reject only,
# v4_all_judgments 는 reject 포함.
# catalog_registered = frame_contracts.yaml 에 contract 있는지 여부.
# false 면 사용자가 override 시도해도 Step 7-A 가 skip (render path 미연결).
# IMP-11 D-2 (u1) : per-candidate min_height_px added (None when unregistered).
"v4_all_judgments": v4_all_judgments_list,
"application_candidates": app_candidates,
# IMP-06 blocker-fix (Codex #13 Blocker 3 / #16) — plan-aware
# additive fields. None / False / [] when no override CLI used.
"position": plan_position,
"assignment_source": plan_assignment_source,
"section_assignment_override": plan_section_override,
"replaced_auto_unit": plan_replaced_auto,
"skipped_collided_auto_units": plan_skipped_collided,
"skipped_reason": plan_skipped_reason,
}
# ─── Main entry ────────────────────────────────────────────────
def run_phase_z2_mvp1(
mdx_path: Path,
run_id: Optional[str] = None,
*,
override_layout: Optional[str] = None,
override_frames: Optional[dict[str, str]] = None,
override_zone_geometries: Optional[dict[str, dict]] = None,
override_section_assignments: Optional[dict[str, list[str]]] = None,
) -> Path:
"""MVP-1.5b entry — single slide + composition planner v0 + 8 preset vocabulary.
Pipeline :
parse_mdx → align_sections_to_v4_granularity → plan_composition →
mapper per unit → render slide_base + frame partial → Selenium check
User overrides (Step 7-A axis, 2026-05-08) :
override_layout : 자동 결정된 layout_preset 을 사용자 선택값으로 강제 (8 preset 중 하나).
override_frames : {unit_id: template_id} — 자동 결정된 frame template 을 사용자 선택값
으로 강제. unit_id = "+".join(source_section_ids) (e.g., "03-1"
또는 "03-1+03-2"). 매칭 unit 의 v4_candidates 에 있는 entry 면
그 entry 의 score / label 도 함께 갱신. 없으면 template_id 만 변경.
"""
mdx_path = Path(mdx_path)
if run_id is None:
run_id = time.strftime("%Y%m%d_%H%M%S") + "_phase_z2"
run_dir = RUNS_DIR / run_id / "phase_z2"
run_dir.mkdir(parents=True, exist_ok=True) # step artifacts 위해 미리 생성
print(f"[Phase Z-2 MVP-1.5b] start — mdx={mdx_path.name}, run_id={run_id}")
# ─── Step 0: 사전 준비 (precondition snapshot) ───
_write_step_artifact(
run_dir, 0, "preconditions",
data={
"v4_source": str(V4_RESULT_PATH.relative_to(PROJECT_ROOT)),
"templates_dir": str(TEMPLATE_DIR.relative_to(PROJECT_ROOT)),
"assets_source_base": str(ASSETS_SOURCE_BASE.relative_to(PROJECT_ROOT)),
"frame_contracts_loaded": len(load_frame_contracts()),
"frame_contracts_template_ids": sorted(load_frame_contracts().keys()),
"v4_label_to_phase_z_status": V4_LABEL_TO_PHASE_Z_STATUS,
"mvp1_allowed_statuses": sorted(MVP1_ALLOWED_STATUSES),
},
step_status="partial",
pipeline_path_connected=True,
inputs=[
"templates/phase_z2/catalog/frame_contracts.yaml",
"tests/matching/v4_full32_result.yaml",
"templates/phase_z2/families/*.html",
"figma_to_html_agent/blocks/",
],
outputs=["step00_preconditions.json"],
note=(
"frame_contracts.yaml 에 등록된 frame 만 mapping 가능. "
"V4 결과의 32 frame 중 다수 (F11/F14/F18 등) 미등록 — Step 0 ⚠ partial."
),
)
# ─── Step 1: MDX 업로드 ───
mdx_source_text = mdx_path.read_text(encoding="utf-8")
(run_dir / "steps").mkdir(exist_ok=True)
(run_dir / "steps" / "step01_mdx_source.md").write_text(mdx_source_text, encoding="utf-8")
_write_step_artifact(
run_dir, 1, "mdx_upload",
data={
"mdx_path": str(mdx_path),
"run_id": run_id,
"run_dir": str(run_dir),
"mdx_source_size_bytes": len(mdx_source_text.encode("utf-8")),
"mdx_source_lines": mdx_source_text.count("\n") + 1,
},
step_status="done",
pipeline_path_connected=True,
inputs=[str(mdx_path)],
outputs=["step01_mdx_upload.json", "step01_mdx_source.md"],
note="MDX 원본 그대로 step01_mdx_source.md 에 복사.",
)
# 1. Parse MDX (V4 무관)
legacy_slide_title, legacy_sections, legacy_footer = parse_mdx(mdx_path)
# IMP-02 — Stage 0 chained adapter dispatch (default OFF canary).
# When env PHASE_Z_STAGE0_ADAPTER_ENABLED=1/true/yes the adapter chain
# (mdx_normalizer + section_parser) replaces legacy parse_mdx output;
# on any contract failure or exception, falls back to legacy with
# fallback_reason recorded in stage0_adapter_diagnostics.
# IMP-03 — 5-tuple return adds stage0_normalized_assets (Step 3 handoff).
(
slide_title,
sections,
slide_footer,
stage0_adapter_diagnostics,
stage0_normalized_assets,
) = _stage0_chained_adapter(
mdx_path, legacy_slide_title, legacy_sections, legacy_footer,
)
_adapter_tag = (
"adapter-used" if stage0_adapter_diagnostics["used"]
else f"legacy({stage0_adapter_diagnostics['fallback_reason'] or 'disabled'})"
)
print(f" parsed : title='{slide_title}', sections={len(sections)} "
f"({[s.section_id for s in sections]}), footer={'yes' if slide_footer else 'no'}, "
f"stage0={_adapter_tag}")
# ─── Step 2: MDX 정규화 ───
# orphans / details 필드는 schema lock — 빈 배열이라도 박혀야
# "검사 안 함" vs "없음" 구분 가능 (사용자 직설 2026-05-07).
# 실제 orphan / details 감지 로직은 별 axis (Step 2 보강).
_write_step_artifact(
run_dir, 2, "normalized",
data={
"slide_title": slide_title,
"slide_footer": slide_footer,
"sections_count": len(sections),
"sections": [
{
"section_id": s.section_id,
"section_num": s.section_num,
"title": s.title,
"raw_content_length": len(s.raw_content),
"raw_content": s.raw_content,
}
for s in sections
],
"orphans": [], # schema lock — 중목차에 안 속한 텍스트 (감지 미구현)
"details": [], # schema lock — <details> 팝업 콘텐츠 (감지 미구현)
# IMP-02 — additive only. enabled/used/fallback_reason + id reconstruction
# trace + count diff. Out of scope: V4 / align / composition.
"stage0_adapter_diagnostics": stage0_adapter_diagnostics,
# IMP-03 — Step 3 handoff (slide-level rich asset list).
# env=OFF / fallback 시 모든 list 가 비어 있음. consumer = Step 3
# rich extractor (PHASE_Z_STEP3_RICH_OBJECTS_ENABLED canary).
"stage0_normalized_assets": stage0_normalized_assets,
},
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. "
"stage0_adapter_diagnostics = IMP-02 chained adapter trace (default OFF canary). "
"stage0_normalized_assets = IMP-03 Step 3 slide-level handoff (popups/images/tables list)."
),
)
# 2. Load V4
v4 = load_v4_result()
# 3. Align sections to V4 granularity (### drill if needed).
# IMP-08 B-3 / Stage 5 R2 : forward override target ids so sub-id
# drag/drop targets force-drill their parent section even when V4
# carries the parent exact key (deterministic drag/drop addressing).
_override_target_sids: list[str] = []
if override_section_assignments:
for _sids in override_section_assignments.values():
for _sid in _sids:
if isinstance(_sid, str) and _sid:
_override_target_sids.append(_sid)
sections = align_sections_to_v4_granularity(
sections,
v4,
override_target_section_ids=_override_target_sids or None,
)
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).
section_content_by_id = {s.section_id: s.raw_content for s in sections}
# IMP-08 B-3 : sub-section ordinal id -> legacy V4 key aliases (e.g. "04-2.1").
# Empty list for canonical (top-level) sections — U1 baseline path is exact-only.
section_alias_by_id: dict[str, list] = {
s.section_id: list(getattr(s, "v4_alias_keys", []) or []) for s in sections
}
v4_fallback_traces: dict[str, dict] = {}
def lookup_fn(sid: str) -> Optional[V4Match]:
match, trace = lookup_v4_match_with_fallback(
v4,
sid,
raw_content=section_content_by_id.get(sid),
alias_keys=section_alias_by_id.get(sid),
)
v4_fallback_traces[sid] = trace
return match
# 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, alias_keys=section_alias_by_id.get(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,
)
comp_debug["v4_fallback_selections"] = list(v4_fallback_traces.values())
# IMP-05 L3 (Codex #10 D4) — Step 20 qualifier fields (additive only, no top-level enum change).
# `fallback_selection_count` = number of sections where rank-2/3 was promoted.
# `selection_paths` = per-section selection_path summary (rank_1 / rank_N_fallback / chain_exhausted).
# Top-level slide status enum (PASS / PARTIAL_COVERAGE / ...) remains stable.
_imp05_selection_paths = [
{
"section_id": sid,
"selection_path": t.get("selection_path"),
"selected_rank": t.get("selected_rank"),
"selected_template_id": t.get("selected_template_id"),
"fallback_trigger": t.get("fallback_reason") if t.get("fallback_used") else None,
}
for sid, t in v4_fallback_traces.items()
]
comp_debug["v4_fallback_summary"] = {
"fallback_used_count": sum(1 for t in v4_fallback_traces.values() if t.get("fallback_used")),
"fallback_selection_count": sum(1 for t in v4_fallback_traces.values() if t.get("fallback_used")),
"chain_exhausted_count": sum(
1 for t in v4_fallback_traces.values()
if t.get("selection_path") == "chain_exhausted"
),
"selection_paths": _imp05_selection_paths,
"policy": (
"IMP-05: rank-1 is kept when usable; rank-2/3 may be promoted only when "
"the earlier rank is not auto-renderable, has no catalog contract, or fails "
"capacity precheck. calculate_fit is not used."
),
}
# IMP-47B u12 — mixed direct+reject first-render admission.
# When initial plan_composition produces a viable layout but at least one
# section remains uncovered (typically chain_exhausted / reject), re-run
# with allow_provisional in the lookup + allow_provisional_fill=True so
# reject sections gain a provisional rank-1 V4Match and a last-resort
# provisional candidate fill. This admits the mixed direct+reject case
# to the AI repair path (IMP-47B u4/u5) on first render. Skipped under
# --override-section-assignments to preserve the operator's plan and
# mirror the IMP-30 u4 retry's section_assignment_plan gate. All-direct
# slides have no uncovered sections so this is a no-op. The all-reject
# case is still handled by the IMP-30 u4 retry block below (initial
# plan_composition returns units=[]).
if units and layout_preset is not None and not override_section_assignments:
_u12_covered_ids: set[str] = set()
for _u in units:
_u12_covered_ids.update(_u.source_section_ids)
_u12_uncovered_ids = [
s.section_id for s in sections if s.section_id not in _u12_covered_ids
]
if _u12_uncovered_ids:
def _lookup_fn_mixed_admission(sid: str) -> Optional[V4Match]:
match, trace = lookup_v4_match_with_fallback(
v4,
sid,
raw_content=section_content_by_id.get(sid),
alias_keys=section_alias_by_id.get(sid),
allow_provisional=True,
)
v4_fallback_traces[sid] = trace
return match
units_mixed, layout_preset_mixed, _comp_debug_mixed = plan_composition(
sections,
_lookup_fn_mixed_admission,
V4_LABEL_TO_PHASE_Z_STATUS,
MVP1_ALLOWED_STATUSES,
capacity_fit_fn=compute_capacity_fit,
v4_candidates_lookup_fn=candidates_lookup_fn,
allow_provisional_fill=True,
)
if units_mixed and layout_preset_mixed is not None:
units = units_mixed
layout_preset = layout_preset_mixed
comp_debug["v4_fallback_selections"] = list(v4_fallback_traces.values())
comp_debug["imp47b_u12_mixed_admission"] = {
"applied": True,
"uncovered_before": _u12_uncovered_ids,
"result_unit_count": len(units_mixed),
"result_layout_preset": layout_preset_mixed,
}
# ── Step 7-A axis : layout override ──
# 사용자가 LayoutPanel 에서 다른 preset 을 선택했을 때 자동 결정값을 강제 변경.
# 길이 mismatch (positions count vs unit count) 는 zone loop 의 fallback (zone_{i})
# 으로 처리됨. 알 수 없는 preset 이면 ValueError.
auto_layout_preset = layout_preset
layout_override_applied = False
if override_layout is not None and override_layout != layout_preset:
if override_layout not in LAYOUT_PRESETS:
raise ValueError(
f"--override-layout '{override_layout}' is not a known preset. "
f"Available: {sorted(LAYOUT_PRESETS.keys())}"
)
print(
f" [override] layout_preset: {layout_preset}{override_layout}",
file=sys.stderr,
)
layout_preset = override_layout
layout_override_applied = True
# IMP-06 (#6 / Codex #6/#7/#10/#11/#12 lock) — zone-section assignment override.
# Applied AFTER final layout_preset resolution. ZONE_ID = layout positions.
# The helper validates unknown zone ids / unknown section ids and builds a
# `position_assignment_plan`. Immediately below, Stage 4 Part 2 rebuilds the
# `units` list aligned with that plan: cli_override entries synthesize a
# CompositionUnit, auto entries reuse the original planner unit, and empty/
# collision-skipped entries become None placeholders. The downstream
# zones_data / debug_zones loop then handles None entries by emitting an
# explicit empty zone record (template_id="__empty__") so the slide grid
# preserves position identity without distorting layout allocation.
section_assignment_plan: Optional[list[dict]] = None
section_assignment_summary: Optional[dict] = None
if override_section_assignments and layout_preset is not None:
positions = list(LAYOUT_PRESETS[layout_preset]["positions"])
# Validate ZONE_IDs against active layout positions (fail-fast).
unknown_zones = [z for z in override_section_assignments if z not in positions]
if unknown_zones:
raise ValueError(
f"--override-section-assignment unknown ZONE_ID(s) {unknown_zones} for "
f"layout '{layout_preset}'. Available positions: {positions}"
)
# Validate section_ids against aligned sections (fail-fast).
aligned_section_ids = {s.section_id for s in sections}
sections_by_id = {s.section_id: s for s in sections}
unknown_sections: list[str] = []
for zid, sids in override_section_assignments.items():
for sid in sids:
if sid not in aligned_section_ids:
unknown_sections.append(sid)
if unknown_sections:
raise ValueError(
f"--override-section-assignment unknown section_id(s) {unknown_sections}. "
f"Aligned sections: {sorted(aligned_section_ids)}"
)
section_assignment_plan, section_assignment_summary = _build_position_assignment_plan(
units=units,
positions=positions,
override_section_assignments=override_section_assignments,
sections_by_id=sections_by_id,
override_frames=override_frames,
v4=v4,
)
comp_debug["section_assignment_plan"] = section_assignment_plan
comp_debug["section_assignment_summary"] = section_assignment_summary
print(
f" [override] section_assignment applied: "
f"{section_assignment_summary['applied_count']} position(s), "
f"{section_assignment_summary['skipped_count']} skipped, "
f"uncovered_sections={section_assignment_summary['uncovered_section_ids']}",
file=sys.stderr,
)
# Stage 4 blocker-fix (Codex #13/#14/#15/#16/#17) — rebuild units as a pure
# `list[CompositionUnit]` (renderable only, no None). Position-aware truth
# lives in `render_records` (built after frame_overrides apply) per Codex
# internal contract: units = canonical renderable list, render_records =
# canonical per-position view including empty/skipped entries.
from src.phase_z2_composition import CompositionUnit
plan_units: list = []
# Maintain ordered alignment with section_assignment_plan for the
# render_records build step below: plan_unit_by_position[pos] = unit | None.
plan_unit_by_position: dict[str, object] = {}
for entry in section_assignment_plan:
assignment_source = entry["assignment_source"]
pos = entry["position"]
if assignment_source == "cli_override" and entry["template_id"] is not None:
sids = entry["source_section_ids"]
raw_content_parts = []
title_parts = []
for sid in sids:
sect = sections_by_id.get(sid)
if sect is None:
continue
raw_content_parts.append(sect.raw_content or "")
if sect.title:
title_parts.append(sect.title)
contract = get_contract(entry["template_id"])
contract_frame_id = (contract or {}).get("frame_id") or ""
override_unit = CompositionUnit(
source_section_ids=list(sids),
merge_type="cli_override",
frame_template_id=entry["template_id"],
frame_id=str(contract_frame_id),
frame_number=0,
confidence=0.0,
label="use_as_is",
phase_z_status="matched_zone",
raw_content="\n\n".join(raw_content_parts),
title=" / ".join(title_parts) if title_parts else "+".join(sids),
v4_rank=None,
selection_path="cli_override",
fallback_reason=None,
score=0.0,
rationale={
"section_assignment_override": entry["section_assignment_override"],
"replaced_auto_unit": entry["replaced_auto_unit"],
},
)
plan_units.append(override_unit)
plan_unit_by_position[pos] = override_unit
elif assignment_source == "auto":
# Find original auto unit by source_section_ids.
matched = None
for u in units:
if list(u.source_section_ids) == entry["source_section_ids"]:
matched = u
break
if matched is not None:
plan_units.append(matched)
plan_unit_by_position[pos] = matched
else:
# Unexpected — auto plan entry without a matching original unit.
plan_unit_by_position[pos] = None
else:
# empty / collision-skipped — NO None in units list, but the position
# is preserved in plan_unit_by_position so render_records can emit
# an empty zone record below (after frame_overrides apply).
plan_unit_by_position[pos] = None
units = plan_units
if not units or layout_preset is None:
# IMP-30 u4 — first-render invariant. The pre-u4 path here was
# `sys.exit(1)` after writing error.json. That violated the invariant
# ("final.html + Step 20 slide_status MUST be written for every input
# where Step 0~5 succeed") whenever V4 evidence for any section was
# restructure/reject (chain_exhausted) or missing (no_v4_section /
# empty_v4_judgments).
#
# Recovery has two phases:
# Phase A — provisional retry (u1 + u3 opt-in). Re-run plan_composition
# with allow_provisional=True (in lookup_fn) and allow_provisional_fill
# =True. Synthesizes rank-1 provisional V4Match on chain_exhausted
# (u1) and last-resort-fills uncovered sections with provisional
# candidates (u3). Skipped when the CLI override path was used —
# re-running plan_composition there would discard the override.
# Phase B — terminal empty-shell. If retry still yields zero units
# (true "no rank-1 V4 anywhere" case, or override path with no
# resolvable assignments), synthesize a single placeholder
# CompositionUnit with frame_template_id="__empty__", layout_preset
# ="single". The per-unit loop's __empty__ guard emits a placeholder
# zones_data / debug_zones record; final.html renders the slide
# base shell (title + footer + empty zone) so the first-render
# invariant holds. Provisional flag = True surfaces the "needs
# adaptation" signal (u5 zone class + u6 status qualifier).
provisional_recovered = False
if section_assignment_plan is None:
def _lookup_fn_provisional(sid: str) -> Optional[V4Match]:
match, trace = lookup_v4_match_with_fallback(
v4,
sid,
raw_content=section_content_by_id.get(sid),
alias_keys=section_alias_by_id.get(sid),
allow_provisional=True,
)
v4_fallback_traces[sid] = trace
return match
units_retry, layout_preset_retry, comp_debug_retry = plan_composition(
sections,
_lookup_fn_provisional,
V4_LABEL_TO_PHASE_Z_STATUS,
MVP1_ALLOWED_STATUSES,
capacity_fit_fn=compute_capacity_fit,
v4_candidates_lookup_fn=candidates_lookup_fn,
allow_provisional_fill=True,
)
comp_debug["imp30_u4_provisional_retry"] = {
"applied": True,
"result_unit_count": len(units_retry),
"result_layout_preset": layout_preset_retry,
"candidates_summary": comp_debug_retry.get("candidates_summary"),
}
if units_retry and layout_preset_retry is not None:
units = units_retry
layout_preset = layout_preset_retry
provisional_recovered = True
# v4_fallback_traces was overwritten by _lookup_fn_provisional;
# refresh the IMP-05 selection_paths telemetry so Step 20 reflects
# the actual selection (provisional_rank_1) rather than the stale
# chain_exhausted state from the first attempt.
_imp05_selection_paths_retry = [
{
"section_id": sid,
"selection_path": t.get("selection_path"),
"selected_rank": t.get("selected_rank"),
"selected_template_id": t.get("selected_template_id"),
"fallback_trigger": (
t.get("fallback_reason") if t.get("fallback_used") else None
),
}
for sid, t in v4_fallback_traces.items()
]
comp_debug["v4_fallback_selections"] = list(v4_fallback_traces.values())
if "v4_fallback_summary" in comp_debug:
comp_debug["v4_fallback_summary"]["selection_paths"] = (
_imp05_selection_paths_retry
)
print(
f" [IMP-30 u4] provisional retry recovered {len(units)} unit(s) "
f"— first-render invariant preserved.",
file=sys.stderr,
)
if not provisional_recovered:
# Phase B — terminal empty-shell. No rank-1 V4 evidence for any
# section, or override path produced no renderable assignments.
from src.phase_z2_composition import CompositionUnit as _CompositionUnit
run_dir.mkdir(parents=True, exist_ok=True)
empty_shell_unit = _CompositionUnit(
source_section_ids=[s.section_id for s in sections],
merge_type="empty_shell",
frame_template_id="__empty__",
frame_id="__empty__",
frame_number=0,
confidence=0.0,
label="empty_shell",
phase_z_status="empty_shell",
raw_content="\n\n".join((s.raw_content or "") for s in sections),
title=" / ".join((s.title or "") for s in sections),
v4_rank=None,
selection_path="empty_shell",
fallback_reason="no_v4_rank_1_for_any_section",
score=0.0,
rationale={
"imp30_u4": "terminal_first_render_empty_shell",
"reason": (
"no_rank_1_V4_evidence_in_any_section"
if section_assignment_plan is None
else "section_assignment_override_yielded_no_renderable_units"
),
"aligned_section_ids": [s.section_id for s in sections],
},
provisional=True,
)
units = [empty_shell_unit]
layout_preset = "single"
comp_debug["imp30_u4_empty_shell"] = {
"applied": True,
"reason": (
"no_rank_1_V4_for_any_section"
if section_assignment_plan is None
else "section_assignment_override_yielded_no_renderable_units"
),
"aligned_section_ids": [s.section_id for s in sections],
}
print(
f"\n[Phase Z-2 IMP-30 u4] EMPTY-SHELL @ composition_planner",
file=sys.stderr,
)
print(
f" reason : "
f"{'no rank-1 V4 evidence for any section' if section_assignment_plan is None else 'override produced no renderable units'}",
file=sys.stderr,
)
print(
f" shell : 1 placeholder unit, preset='single' "
f"(sections={[s.section_id for s in sections]})",
file=sys.stderr,
)
print(f" preset : {layout_preset} ({len(units)} units, composition v0 count-based)")
for u in units:
print(f" unit : {u.source_section_ids} merge={u.merge_type}"
f"frame {u.frame_number} ({u.frame_template_id}) "
f"label={u.label} score={u.score:.3f}")
# ─── Step 6: Composition Planning ───
_write_step_artifact(
run_dir, 6, "composition_plan",
data={
"selected_units_count": len(units),
"layout_preset_decided": layout_preset,
"candidates_summary": comp_debug.get("candidates_summary"),
"candidates_total": comp_debug.get("candidates_total"),
"candidates_viable_auto": comp_debug.get("candidates_viable_auto"),
"selected_units": [
{
"source_section_ids": u.source_section_ids,
"merge_type": u.merge_type,
"frame_id": u.frame_id,
"frame_number": u.frame_number,
"frame_template_id": u.frame_template_id,
"label": u.label,
"v4_rank": u.v4_rank,
"selection_path": u.selection_path,
"fallback_reason": u.fallback_reason,
"score": u.score,
"phase_z_status": u.phase_z_status,
"rationale": u.rationale,
"notes": list(u.notes),
# Step 6-A axis (사용자 lock 2026-05-08) — V4 후보 list.
# 단일 frame_* / label / confidence 와 일관 (candidates[0] = rank-1 non-reject).
"v4_candidates": [
{
"template_id": c.template_id,
"frame_id": c.frame_id,
"frame_number": c.frame_number,
"confidence": c.confidence,
"label": c.label,
}
for c in u.v4_candidates
],
}
for u in units
],
},
step_status="done",
pipeline_path_connected=True,
inputs=["step02_normalized.json", "step05_v4_evidence.json"],
outputs=["step06_composition_plan.json"],
note=(
"composition v0 count-based — sections → candidates → score → greedy select. "
"Step 6-A (사용자 lock 2026-05-08): selected_units[i].v4_candidates 추가 "
"(non-reject max-6 후보 list, candidates[0] = 단일 frame_* 와 일관). "
"logic 무변 — runtime 결과 동일. Step 9 application_plan input."
),
)
# 5. Per-unit: synthesize MdxSection → mapper → assets → zone data
# mapper FitError 는 catch — 자동 파이프라인은 다른 zone 계속 진행. abort X.
positions = LAYOUT_PRESETS[layout_preset]["positions"]
zones_data = []
debug_zones = []
adapter_needed_units: list[dict] = []
# ── Step 7-A axis : frame override ──
# {unit_id: template_id} 형식. unit_id 매칭 시 unit.frame_template_id 강제 변경.
# v4_candidates 안에서 같은 template_id 를 가진 entry 를 찾으면 frame_id /
# frame_number / confidence / label 까지 그 entry 에서 가져와 갱신 — 그래야 step09
# artifact 의 메타가 일관됨. IMP-47B u3 (2026-05-21) : v4_candidates miss 시
# 전 32 judgments 까지 probe — reject 라벨 frame 을 사용자가 선택한 경우
# unit 을 provisional=True 로 승격해 Step 12 AI 재구성 게이트를 통과시킴
# (frame 유지, 자동 frame swap 금지 — [[feedback_ai_isolation_contract]]).
# frame contract 가 catalog 에 등록 안 된 template_id 면 skip + warning —
# crash 방지 (V4 score 는 매겨지지만 catalog partial 은 없는 후보 존재).
frame_overrides_applied: list[dict] = []
frame_overrides_skipped: list[dict] = []
if override_frames:
for unit in units:
unit_id = "+".join(unit.source_section_ids)
if unit_id not in override_frames:
continue
new_tid = override_frames[unit_id]
old_tid = unit.frame_template_id
if new_tid == old_tid:
continue
# catalog contract 존재 확인 — 없으면 override 거부.
new_contract = get_contract(new_tid)
if new_contract is None:
frame_overrides_skipped.append({
"unit_id": unit_id,
"from": old_tid,
"to": new_tid,
"reason": "no_frame_contract_in_catalog",
})
print(
f" [override-skip] unit {unit_id}: '{new_tid}' has no entry in "
f"frame_contracts catalog — keeping {old_tid}",
file=sys.stderr,
)
continue
meta_source = _apply_frame_override_to_unit(unit, new_tid, v4)
frame_overrides_applied.append({
"unit_id": unit_id,
"from": old_tid,
"to": new_tid,
"meta_source": meta_source,
})
print(
f" [override] unit {unit_id} frame: {old_tid}{new_tid} "
f"({meta_source})",
file=sys.stderr,
)
# IMP-06 blocker-fix (Codex #13/#14/#15/#16/#17) — build render_records AFTER
# frame_overrides so each record points to the post-override CompositionUnit
# object (Codex #15 Catch R). render_records is the canonical per-position
# plan-derived view: cli_override / auto / empty / collision-skipped all carry
# a record. `unit` is the same instance reference as in `units` when
# renderable, otherwise None. This drives debug_zones/Step 9/Step 20 traces
# without forcing None into the renderable `units` list.
render_records: list[dict] = []
if section_assignment_plan is not None:
for entry in section_assignment_plan:
pos = entry["position"]
unit_ref = plan_unit_by_position.get(pos)
render_records.append({
"position": pos,
"assignment_source": entry["assignment_source"],
"unit": unit_ref,
"source_section_ids": list(entry.get("source_section_ids") or []),
"section_assignment_override": entry.get("section_assignment_override"),
"replaced_auto_unit": entry.get("replaced_auto_unit"),
"skipped_collided_auto_units": list(entry.get("skipped_collided_auto_units") or []),
"uncovered_section_ids": list(entry.get("uncovered_section_ids") or []),
"skipped_reason": entry.get("skipped_reason"),
})
# IMP-06 blocker-fix (Codex #13/#14/#15/#16/#17) — index render_records by
# unit object identity so the renderable zones loop below can stamp each
# zones_data / debug_zones entry with plan-aware fields (assignment_source,
# section_assignment_override, replaced_auto_unit, skipped_collided_auto_units,
# uncovered_section_ids, skipped_reason). Empty positions handled later by
# the post-loop "empty zone records" block — those records carry the same
# plan-aware fields directly from their plan entry.
render_record_by_unit_id: dict[int, dict] = {}
if render_records:
for rec in render_records:
u = rec.get("unit")
if u is not None:
render_record_by_unit_id[id(u)] = rec
for i, unit in enumerate(units):
position = positions[i] if i < len(positions) else f"zone_{i}"
plan_record = render_record_by_unit_id.get(id(unit))
# When render_records exists (override path) prefer its position to
# guard against future drifts. positions[i] is the legacy auto path
# and is byte-identical to plan_record.position in the normal case.
if plan_record is not None and plan_record.get("position"):
position = plan_record["position"]
# IMP-30 u4 — empty-shell synthesized unit. frame_template_id="__empty__"
# has no catalog contract by design; bypass mapper/contract path and emit
# a placeholder zone record so render_slide() short-circuits to empty
# partial_html (existing `__empty__` branch at render_slide:2106). The
# slide_base still renders title + footer + empty grid cell so the
# first-render invariant holds; u5 will surface the provisional flag as
# a zone class + needs-adaptation badge.
if unit.frame_template_id == "__empty__":
zones_data.append({
"position": position,
"template_id": "__empty__",
"slot_payload": {},
"content_weight": {"score": 0},
"min_height_px": 0,
"assignment_source": "imp30_u4_empty_shell",
"section_assignment_override": False,
"provisional": bool(getattr(unit, "provisional", False)),
})
debug_zones.append({
"position": position,
"source_section_ids": list(unit.source_section_ids),
"merge_type": unit.merge_type,
"title": unit.title,
"v4_rank1_frame_id": unit.frame_id,
"v4_rank1_frame_number": unit.frame_number,
"v4_template_id": "__empty__",
"v4_label": unit.label,
"v4_confidence": float(unit.confidence or 0.0),
"v4_selected_rank": unit.v4_rank,
"selection_path": unit.selection_path,
"fallback_reason": unit.fallback_reason,
"fallback_used": False,
"phase_z_status": unit.phase_z_status,
"composition_score": float(unit.score or 0.0),
"composition_rationale": dict(unit.rationale or {}),
"composition_notes": list(unit.notes),
"mapper_type": "empty_shell",
"contract_id": "__empty__",
"contract_frame_id": None,
"builder": None,
"min_height_px": 0,
"slot_payload_keys": [],
"content_truncated_count": None,
"assets_dir": None,
"content_weight": {"score": 0},
"placement_trace": None,
"assignment_source": "imp30_u4_empty_shell",
"section_assignment_override": False,
"replaced_auto_unit": None,
"skipped_collided_auto_units": [],
"uncovered_section_ids": [],
"skipped_reason": "imp30_u4_empty_shell_no_v4_evidence",
"provisional": bool(getattr(unit, "provisional", False)),
})
continue
synth_section = MdxSection(
section_id="+".join(unit.source_section_ids),
section_num=0,
title=unit.title,
raw_content=unit.raw_content,
)
contract = get_contract(unit.frame_template_id)
builder_name = contract["payload"].get("builder")
visual_hints = contract.get("visual_hints") or {}
min_height_px = visual_hints.get("min_height_px", DEFAULT_ZONE_MIN_HEIGHT_PX)
contract_frame_id = contract.get("frame_id")
# ─── trace-only runtime 연결 v0 — B1 → B4 chain (final.html 영향 X) ───
# B1~B4 의 dormant chain 을 *real MDX runtime data* 로 처음 호출.
# 결과 (PlacementPlan) = debug_zones[i].placement_trace 로 *기록만*.
# render path / mapper output / final.html 모두 미변경 — B5 baseline SHA 유지.
# B4 frame selection = catalog declaration order (V4 evidence 미사용 — 별 axis).
# Option 1 (PHASE_Z_B4_SOURCE_SHAPE_ENABLED, default OFF) : pilot = F13 top_bullets only.
b4_source_shape_enabled = (
os.environ.get("PHASE_Z_B4_SOURCE_SHAPE_ENABLED", "").strip().lower()
in {"1", "true", "yes"}
)
b1_source_shape = (
contract.get("source_shape")
if b4_source_shape_enabled and contract.get("source_shape") == "top_bullets"
else None
)
content_objects = extract_content_objects(synth_section, source_shape=b1_source_shape)
placement_plan = plan_placement(
content_objects=content_objects,
frame_contracts=list(load_frame_contracts().values()),
section_id=synth_section.section_id,
)
mapper_frame_template_id = unit.frame_template_id
matches_mapper = (
placement_plan.selected_template_id == mapper_frame_template_id
)
match_note: Optional[str] = None
if not matches_mapper:
if placement_plan.selected_template_id is None:
match_note = "no_frame_covers_content_types"
else:
match_note = (
f"B4 selected '{placement_plan.selected_template_id}' but "
f"mapper uses '{mapper_frame_template_id}' (composition V4 rank-1)"
)
placement_trace = {
**asdict(placement_plan),
"mapper_frame_template_id": mapper_frame_template_id,
"frame_selection_matches_mapper": matches_mapper,
"frame_selection_match_note": match_note,
}
# ─── end trace-only runtime 연결 v0 ───
# ─── B4 gatekeeper (Q-V4B4 / PHASE_Z_B4_GATEKEEPER, default OFF) ───
if (
os.environ.get("PHASE_Z_B4_GATEKEEPER", "").strip().lower()
in {"1", "true", "yes"}
and not matches_mapper
):
adapter_record = {
"position": position,
"source_section_ids": unit.source_section_ids,
"merge_type": unit.merge_type,
"template_id": unit.frame_template_id,
"reason": "v4_b4_mismatch",
"mismatch_detail": {
"v4_template_id": mapper_frame_template_id,
"b4_selected_template_id": placement_plan.selected_template_id,
"match_note": match_note,
},
}
adapter_needed_units.append(adapter_record)
print(f" adapter : zone--{position} {unit.source_section_ids}"
f"{unit.frame_template_id} v4_b4_mismatch → adapter_needed (skip render)")
continue
# ─── end B4 gatekeeper ───
# mapper 시도 — 실패 (FitError) 시 zone 을 adapter_needed 로 표시하고 skip
try:
slot_payload = map_mdx_to_slots(synth_section, unit.frame_template_id)
except FitError as e:
adapter_record = {
"position": position,
"source_section_ids": unit.source_section_ids,
"merge_type": unit.merge_type,
"template_id": unit.frame_template_id,
"reason": "fit_error",
"fit_error": str(e),
}
adapter_needed_units.append(adapter_record)
print(f" adapter : zone--{position} {unit.source_section_ids}"
f"{unit.frame_template_id} FitError → adapter_needed (skip render)")
continue
run_dir.mkdir(parents=True, exist_ok=True)
assets_dir = copy_assets(unit.frame_template_id, run_dir)
content_weight = compute_content_weight(synth_section)
truncated_count = slot_payload.get("_truncated_count") # builder 가 truncate 한 경우
# IMP-06 blocker-fix (Codex #13/#14/#15/#16/#17) — plan-aware additive
# fields. When no override CLI was used (plan_record is None), these
# default to None/False/[] so pre-IMP-06 readers see byte-equivalent
# data. Empty zone records appended below (post-loop) carry the same
# field shape from their plan entry directly.
plan_assignment_source = (
plan_record.get("assignment_source") if plan_record else None
)
plan_section_override = (
bool(plan_record.get("section_assignment_override"))
if plan_record else False
)
plan_replaced_auto = (
plan_record.get("replaced_auto_unit") if plan_record else None
)
plan_skipped_collided = list(
plan_record.get("skipped_collided_auto_units") or []
) if plan_record else []
plan_uncovered = list(
plan_record.get("uncovered_section_ids") or []
) if plan_record else []
plan_skipped_reason = (
plan_record.get("skipped_reason") if plan_record else None
)
# IMP-30 u5 — `provisional` flag flows as data through V4Match (u1) →
# CompositionUnit (u2) → zones_data here. slide_base.html reads
# zone.provisional to apply the `zone--provisional` class + inline
# needs-adaptation badge. Default False keeps non-provisional zones
# byte-identical to pre-u5; only u1-synthesized rank-1 fills or u4
# empty-shell synthesize provisional=True units.
zones_data.append({
"position": position,
"template_id": unit.frame_template_id,
"slot_payload": slot_payload,
"content_weight": content_weight,
"min_height_px": min_height_px,
"assignment_source": plan_assignment_source,
"section_assignment_override": plan_section_override,
"provisional": bool(getattr(unit, "provisional", False)),
})
debug_zones.append({
"position": position,
"source_section_ids": unit.source_section_ids,
"merge_type": unit.merge_type,
"title": unit.title,
"v4_rank1_frame_id": unit.frame_id,
"v4_rank1_frame_number": unit.frame_number,
"v4_template_id": unit.frame_template_id,
"v4_label": unit.label,
"v4_confidence": unit.confidence,
"v4_selected_rank": unit.v4_rank,
"selection_path": unit.selection_path,
"fallback_reason": unit.fallback_reason,
"fallback_used": bool(unit.selection_path and "fallback" in unit.selection_path),
"phase_z_status": unit.phase_z_status,
"composition_score": unit.score,
"composition_rationale": unit.rationale,
"composition_notes": list(unit.notes), # parent_merged_inferred 정보 신호
"mapper_type": "contract",
"contract_id": unit.frame_template_id,
"contract_frame_id": contract_frame_id,
"builder": builder_name,
"min_height_px": min_height_px,
"slot_payload_keys": sorted(slot_payload.keys()),
"content_truncated_count": truncated_count, # None / N (builder 가 N 개 자름)
"assets_dir": str(assets_dir.relative_to(run_dir)) if assets_dir else None,
"content_weight": content_weight,
# trace-only runtime 연결 v0 — B1 → B2 → B4 chain 결과 (render 미영향).
"placement_trace": placement_trace,
# IMP-06 blocker-fix — plan-aware additive fields.
"assignment_source": plan_assignment_source,
"section_assignment_override": plan_section_override,
"replaced_auto_unit": plan_replaced_auto,
"skipped_collided_auto_units": plan_skipped_collided,
"uncovered_section_ids": plan_uncovered,
"skipped_reason": plan_skipped_reason,
# IMP-30 u5 — provisional signal mirror for debug.json consumers.
"provisional": bool(getattr(unit, "provisional", False)),
})
# IMP-06 blocker-fix (Codex #10 Catch N) — append explicit empty zone records
# for positions whose section-assignment plan produced no renderable unit
# (cli_override with no resolvable template, ad-hoc multi-section,
# override_collision whole-skip with no replacement, etc.). Empty records
# preserve zone identity in zones_data/debug_zones (template_id="__empty__",
# content_weight=0, min_height_px=0) so layout/grid stay structurally
# consistent without distorting allocation, and the partial-render loop
# short-circuits "__empty__" to an empty string (no TemplateNotFound).
if render_records:
renderable_positions = {z["position"] for z in zones_data}
for record in render_records:
pos = record["position"]
if pos in renderable_positions:
continue
zones_data.append({
"position": pos,
"template_id": "__empty__",
"slot_payload": {},
"content_weight": {"score": 0},
"min_height_px": 0,
"assignment_source": "empty",
"skipped_reason": (
record.get("skipped_reason")
or "section_assignment_override_empty_or_unrenderable"
),
})
debug_zones.append({
"position": pos,
"source_section_ids": list(record.get("source_section_ids") or []),
"merge_type": "empty",
"title": "",
"v4_template_id": "__empty__",
"v4_label": None,
"v4_confidence": 0.0,
"selection_path": "section_assignment_empty",
"fallback_reason": None,
"fallback_used": False,
"phase_z_status": None,
"composition_score": 0.0,
"composition_rationale": {},
"composition_notes": [],
"mapper_type": "empty",
"contract_id": "__empty__",
"contract_frame_id": None,
"assignment_source": record.get("assignment_source"),
"skipped_reason": (
record.get("skipped_reason")
or "section_assignment_override_empty_or_unrenderable"
),
"section_assignment_override": record.get("section_assignment_override"),
"replaced_auto_unit": record.get("replaced_auto_unit"),
"skipped_collided_auto_units": list(record.get("skipped_collided_auto_units") or []),
"uncovered_section_ids": list(record.get("uncovered_section_ids") or []),
})
# ─── Step 3: Content Object 추출 (B1, trace-only) ───
# IMP-03 — slide-level rich ContentObject 추출 (default OFF canary).
# scope-lock 16 조건 (Gitea #3) :
# - 별 함수 (extract_rich_content_objects) — v0 extract_content_objects unchanged
# - slide-level — section_id=None, id=`{mdx_id}.{type}-N`, scope='slide'
# - root-level once (per-zone duplication X)
# - plan_placement() 는 v0 list 만 받음 (B4 회귀 X) — 본 rich 결과는 artifact only
# - transform_table dedup : arrow row 감지 시 skip
rich_flag = os.environ.get("PHASE_Z_STEP3_RICH_OBJECTS_ENABLED", "").strip().lower()
rich_enabled_flag = rich_flag in {"1", "true", "yes"}
_assets_total = (
len(stage0_normalized_assets.get("popups") or [])
+ len(stage0_normalized_assets.get("images") or [])
+ len(stage0_normalized_assets.get("tables") or [])
)
rich_disabled_reason: Optional[str] = None
if not rich_enabled_flag:
rich_disabled_reason = "FLAG_OFF"
elif _assets_total == 0:
rich_disabled_reason = "NO_NORMALIZED_ASSETS"
rich_objects: list = []
rich_skips: list = []
if rich_disabled_reason is None:
mdx_num_match = re.match(r"(\d+)", mdx_path.stem)
rich_mdx_id = mdx_num_match.group(1).zfill(2) if mdx_num_match else "00"
rich_objects, rich_skips = extract_rich_content_objects(
stage0_normalized_assets, mdx_id=rich_mdx_id,
)
# Count/list invariant check (IMP-02 ↔ IMP-03 chain) — soft warning, no fail.
invariant_warnings: list[dict] = []
_adapter_counts = (stage0_adapter_diagnostics or {}).get("adapter_counts") or {}
if _adapter_counts:
for key in ("popups", "images", "tables"):
expected = _adapter_counts.get(key)
actual = len(stage0_normalized_assets.get(key) or [])
if expected is not None and expected != actual:
invariant_warnings.append({
"field": key,
"adapter_counts": expected,
"stage0_normalized_assets_len": actual,
})
_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
],
# IMP-03 — slide-level rich trace (additive, trace-only).
"rich_content_objects": [asdict(o) for o in rich_objects],
"rich_content_objects_enabled": rich_disabled_reason is None,
"rich_content_objects_scope": "slide",
"rich_content_objects_source": "stage0_normalized_assets",
"rich_content_objects_disabled_reason": rich_disabled_reason,
"rich_content_objects_skips": rich_skips,
"rich_content_objects_invariant_warnings": invariant_warnings,
},
step_status="trace-only",
pipeline_path_connected=False,
inputs=["step02_normalized.json"],
outputs=["step03_content_objects.json"],
note=(
"현재는 trace 로 기록되지만 render payload 를 직접 만들지는 않음. "
"mapper.py 가 별도로 MDX 직접 파싱. "
"IMP-03 rich_content_objects = slide-level popup/image/table trace "
"(PHASE_Z_STEP3_RICH_OBJECTS_ENABLED canary, default OFF)."
),
)
# ─── 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_selected_rank": dz.get("v4_selected_rank"),
"v4_template_id": dz.get("v4_template_id"),
"v4_confidence": dz.get("v4_confidence"),
"v4_label": dz.get("v4_label"),
"selection_path": dz.get("selection_path"),
"fallback_reason": dz.get("fallback_reason"),
"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)
# IMP-08 N-R6 diagnostic exemption : this report path is post-decision
# reporting only. Runtime selection goes through _resolve_v4_section_key
# (4 sites). Direct dict lookup here is intentional — debug_zones carries
# dict-shape entries without v4_alias_keys plumbing, and a miss here only
# yields a "V4 entry missing" report line (runtime impact zero).
try:
with open(V4_RESULT_PATH, encoding="utf-8") as _vf:
_v4_full = yaml.safe_load(_vf)
_v4_sections = (_v4_full or {}).get("mdx_sections", {}) or {}
except Exception:
_v4_sections = {}
_candidates_html = ""
# path from steps/ 에서 figma_to_html_agent/blocks/{frame_id}/index.html
# steps → phase_z2 → run_id → runs → data → design_agent (5 levels up)
_frame_iframe_base = "../../../../../figma_to_html_agent/blocks"
for dz in debug_zones:
_section_ids = dz.get("source_section_ids", [])
_candidates_html += f"<h2>zone--{dz['position']} (sections: {', '.join(_section_ids)})</h2>"
for _sid in _section_ids:
_sec = _v4_sections.get(_sid)
if not _sec:
_candidates_html += f"<p>section <code>{_sid}</code>: V4 entry 없음</p>"
continue
_candidates_html += (
f"<h3>section <code>{_sid}</code>: {_sec.get('mdx_title', '?')}</h3>"
f"<p>answer_frame_number: <strong>{_sec.get('answer_frame_number', '-')}</strong>"
f" | is_holdout: {_sec.get('is_holdout', '-')}</p>"
f'<div style="display:grid;grid-template-columns:repeat(2,1fr);gap:16px;margin-bottom:24px;">'
)
for _j in (_sec.get("judgments_full32") or [])[:4]:
_rank = _j.get("v4_full_rank")
_frame_id = _j.get("frame_id")
_frame_index = ASSETS_SOURCE_BASE / str(_frame_id) / "index.html"
_has_preview = _frame_index.exists()
_css = "candidate-card candidate-rank1" if _rank == 1 else "candidate-card"
_preview_html = (
f'<div style="margin-top:8px;width:320px;height:180px;overflow:hidden;'
f'border:1px solid #cbd5e1;background:#fff;">'
f'<iframe src="{_frame_iframe_base}/{_frame_id}/index.html" '
f'style="width:1280px;height:720px;border:none;'
f'transform:scale(0.25);transform-origin:0 0;"></iframe>'
f'</div>'
if _has_preview else
f'<div style="margin-top:8px;width:320px;height:180px;'
f'border:1px dashed #cbd5e1;background:#f8fafc;display:flex;'
f'align-items:center;justify-content:center;color:#94a3b8;font-size:13px;">'
f'preview missing<br>'
f'(figma_to_html_agent/blocks/{_frame_id}/index.html 없음)'
f'</div>'
)
_candidates_html += (
f'<div class="{_css}">'
f'<strong>rank {_rank}: frame {_j.get("frame_number")} '
f'(<code>{_j.get("template_id")}</code>)</strong><br>'
f'confidence: {_j.get("confidence", 0):.4f} | '
f'label: <strong>{_j.get("label", "-")}</strong> | '
f'base: {_j.get("base", 0):.4f} | penalty: {_j.get("penalty", 0)}'
f'{_preview_html}'
f"</div>"
)
_candidates_html += "</div>"
_write_step_html(
run_dir, 9, "frame_matching_candidates",
title="V4 Frame Matching Candidates (top 4 per section)",
body_html=(
f"<p>각 zone 의 source section 마다 V4 rank 1~4 후보 표시. "
f"녹색 강조 = rank-1 (현재 선택된 frame).</p>{_candidates_html}"
),
step_status="done",
inputs=["step05_v4_evidence.json", "tests/matching/v4_full32_result.yaml"],
outputs=["step09_frame_selection.json", "step09_frame_matching_candidates.html"],
)
# ─── Step 10: Frame Contract 확인 ───
contracts_per_zone = []
for dz in debug_zones:
c = get_contract(dz.get("contract_id"))
contracts_per_zone.append({
"position": dz["position"],
"contract_id": dz.get("contract_id"),
"frame_id": (c or {}).get("frame_id"),
"family": (c or {}).get("family"),
"source_shape": (c or {}).get("source_shape"),
"cardinality": (c or {}).get("cardinality"),
"visual_hints": (c or {}).get("visual_hints"),
"accepted_content_types": (c or {}).get("accepted_content_types"),
"sub_zones": (c or {}).get("sub_zones"),
"payload_builder": ((c or {}).get("payload") or {}).get("builder"),
"payload_builder_options": ((c or {}).get("payload") or {}).get("builder_options"),
})
_write_step_artifact(
run_dir, 10, "frame_contract",
data={"per_zone": contracts_per_zone},
step_status="partial",
pipeline_path_connected=True,
inputs=["step09_frame_selection.json", "templates/phase_z2/catalog/frame_contracts.yaml"],
outputs=["step10_frame_contract.json"],
note="full contract dict (sub_zones / cardinality / visual_hints 포함). density envelope 미선언 (별 axis).",
)
# ─── Step 11: Slot Mapping (B4 placement, trace-only) ───
_write_step_artifact(
run_dir, 11, "slot_mapping",
data={
"per_zone": [
{
"position": dz["position"],
"selected_template_id": (dz.get("placement_trace") or {}).get("selected_template_id"),
"slot_assignments": (dz.get("placement_trace") or {}).get("slot_assignments") or [],
"rejection": (dz.get("placement_trace") or {}).get("rejection") or [],
"overflow_buffer": (dz.get("placement_trace") or {}).get("overflow_buffer") or [],
}
for dz in debug_zones
],
},
step_status="trace-only",
pipeline_path_connected=False,
inputs=["step04_internal_composition.json", "step10_frame_contract.json"],
outputs=["step11_slot_mapping.json"],
note="B4 PlacementPlan slot_assignments — render path 미연결. 실제 render slot 매핑은 mapper.py 의 builder.",
)
# ─── Step 12 IMP-47B u4 — AI repair proposal gather ───
# Wire gather_step12_ai_repair_proposals so reject / restructure
# provisional units reach the AI fallback router. Normal-path units
# (use_as_is / light_edit / non-provisional) skip via the catch-all
# route gate; flag-off runs short-circuit at the router. Stored locally
# for u5 (PARTIAL_OVERRIDES apply) + u6 (step12_ai_repair.json audit).
ai_repair_records = _run_step12_ai_repair(units)
# ─── Step 12 IMP-47B u5 — Apply PARTIAL_OVERRIDES proposals ───
# Mirror the per-unit position derivation from the render loop above
# (L3789-3796); apply merges slots into zone slot_payload, loud-fails
# unsupported kinds via apply_status marker.
unit_positions: list[str] = []
for _i, _unit in enumerate(units):
_pos = positions[_i] if _i < len(positions) else f"zone_{_i}"
_plan_record = render_record_by_unit_id.get(id(_unit))
if _plan_record is not None and _plan_record.get("position"):
_pos = _plan_record["position"]
unit_positions.append(_pos)
_apply_ai_repair_proposals_to_zones(ai_repair_records, unit_positions, zones_data)
# ─── Step 12 IMP-47B u7 — Post-AI source_section_ids coverage invariant ───
# Structural defense: AI repair must not silently drop a unit's
# source_section_ids. dropped 절대 룰 — text_block / table / image /
# details deletion forbidden. Result feeds u6 audit (below) and
# u8 slide_status.ai_repair_status surfacing.
ai_repair_coverage_invariant = _check_post_ai_coverage_invariant(
units, ai_repair_records,
)
# ─── Step 12 IMP-47B u6 — AI repair audit artifact ───
# Persist per-unit gather/apply outcomes (route_hint, skip_reason,
# apply_status, ai_called, proposal kind, cache_key, fingerprints)
# so reviewers can audit which units reached the AI fallback router
# and what happened. Flag-off default → every record has
# ai_called=False + apply_status='no_proposal'; flag-on +
# provisional reject/restructure → router_short_circuit (cache miss
# without client) or applied:partial_overrides (cache hit / live AI).
# u7 coverage_invariant rides alongside per_unit for reviewers.
_write_step_artifact(
run_dir, 12, "ai_repair",
data={
"per_unit": ai_repair_records,
"coverage_invariant": ai_repair_coverage_invariant,
},
step_status="done",
pipeline_path_connected=True,
inputs=["step10_frame_contract.json", "step02_normalized.json"],
outputs=["step12_ai_repair.json"],
note="IMP-47B u6 — Step 12 AI repair gather + apply records per unit (route, skip_reason, apply_status, proposal). u7 coverage_invariant = pre/post AI source_section_ids set comparison.",
)
# ─── Step 12: Slot Payload (actual values, mapper.py 결과) ───
_write_step_artifact(
run_dir, 12, "slot_payload",
data={
"per_zone": [
{
"position": zd["position"],
"template_id": zd["template_id"],
"builder": (get_contract(zd["template_id"]) or {}).get("payload", {}).get("builder"),
"slot_payload": zd["slot_payload"],
"content_weight": zd.get("content_weight"),
"min_height_px": zd.get("min_height_px"),
}
for zd in zones_data
],
},
step_status="done",
pipeline_path_connected=True,
inputs=["step10_frame_contract.json", "step02_normalized.json"],
outputs=["step12_slot_payload.json"],
note="map_with_contract 결과 — actual slot_payload 값 그대로 (key 만 X).",
)
# 6. Build layout CSS — horizontal-2 = dynamic heights (regression preserve), 그 외 = fr default.
# Step D-ext : override_zone_geometries 가 들어오면 layout_css 강제.
layout_css = build_layout_css(
layout_preset, zones_data, override_zone_geometries=override_zone_geometries
)
# IMP-09 PR 1 — unified per-zone geometry aggregation across all
# layouts. Spanning zones in 2-D layouts (T / 2x2 from PR 2 onward)
# are handled by _compute_per_zone_geometry; in PR 1 the helper
# operates on row/col-static or row-dynamic / col-dynamic outputs.
per_zone_geo = _compute_per_zone_geometry(layout_css, debug_zones, GRID_GAP)
for dz, geo in zip(debug_zones, per_zone_geo):
dz["height_px"] = geo["zone_height_px"]
dz["ratio"] = geo["zone_height_ratio"]
dz["width_px"] = geo["zone_width_px"]
dz["width_ratio"] = geo["zone_width_ratio"]
if layout_css["dynamic_rows"] and layout_css.get("dynamic_cols"):
print(
f" zones : 2-D heights {layout_css['heights_px']} px, "
f"widths {layout_css['widths_px']} px, "
f"ratios {layout_css['ratios']}, "
f"width_ratios {layout_css['width_ratios']}"
)
elif layout_css["dynamic_rows"]:
print(f" zones : heights {layout_css['heights_px']} px, ratios {layout_css['ratios']}")
elif layout_css.get("dynamic_cols"):
print(f" zones : widths {layout_css['widths_px']} px, width_ratios {layout_css['width_ratios']}")
else:
print(f" zones : fr default ({layout_css['cols']} / {layout_css['rows']})")
# ─── Step 7: Slide-Level Layout (선택된 layout + 후보 list — Step 7-conn) ───
# Step 7-conn axis (사용자 lock 2026-05-08) — select_layout_candidates(unit_count)
# 결과를 artifact 에 기록 (passive). default 결정 자체는 무변 (layout_preset 그대로).
layout_candidates_list = select_layout_candidates(len(units))
_write_step_artifact(
run_dir, 7, "layout",
data={
"layout_preset": layout_preset,
"layout_css": layout_css,
"zones_count": len(zones_data),
"unit_count": len(units),
"layout_candidates": layout_candidates_list,
# Step 7-A axis : user override trace
"layout_override_applied": layout_override_applied,
"auto_layout_preset": auto_layout_preset,
},
step_status="partial",
pipeline_path_connected=True,
inputs=["step06_composition_plan.json"],
outputs=["step07_layout.json", "step07_selected_layout.html"],
note=(
"count-based v0 — 들여쓰기 / 정렬 미세 layout 미구현 (Step 7 ⚠ partial). "
"Step 7-conn (사용자 lock 2026-05-08): unit_count → layout_candidates list "
"(select_layout_candidates) artifact 기록. default 결정 자체는 무변 — "
"candidates[0] 가 default (default_selection: true), 나머지는 alternative. "
"Step 9 application_plan input."
),
)
# Step 7 HTML — slide-base 위 실제 zone 박스 위치 시각화 (constants 와 1:1 일치)
_slide_w, _slide_h, _body_h = SLIDE_W, SLIDE_H, SLIDE_BODY_HEIGHT
_zone_palette = ["#dbeafe", "#fef3c7", "#dcfce7", "#fce7f3", "#ede9fe"]
_zone_borders = ["#3b82f6", "#f59e0b", "#16a34a", "#db2777", "#7c3aed"]
_grid_template = layout_css.get("rows") or "1fr"
_zone_visuals_7 = ""
for i, dz in enumerate(debug_zones):
_color = _zone_palette[i % len(_zone_palette)]
_border = _zone_borders[i % len(_zone_borders)]
_zone_visuals_7 += (
f'<div style="background:{_color};border:3px solid {_border};'
f'display:flex;flex-direction:column;justify-content:center;align-items:center;'
f'padding:16px;text-align:center;">'
f'<div style="font-size:18px;font-weight:700;margin-bottom:6px;">zone--{dz["position"]}</div>'
f'<div style="font-size:14px;"><code>{dz.get("v4_template_id") or "-"}</code></div>'
f'<div style="font-size:13px;color:#475569;margin-top:4px;">'
f'height: {dz.get("height_px") or "?"}px / ratio: {dz.get("ratio") or "?"}'
f'</div></div>'
)
_slide_visual_7 = (
f'<div style="width:{_slide_w}px;border:2px solid #1e293b;background:#fff;margin:16px 0;'
f'box-shadow:0 4px 16px rgba(0,0,0,0.1);">'
f'<div style="padding:14px 40px;border-bottom:1px solid #cbd5e1;background:#f8fafc;">'
f'<div style="font-size:15px;color:#64748b;">[slide-title placeholder]</div></div>'
f'<div style="padding:0 40px;height:{_body_h}px;display:grid;'
f'grid-template-rows:{_grid_template};grid-template-columns:{layout_css.get("cols") or "1fr"};'
f'gap:{GRID_GAP}px;box-sizing:border-box;padding-top:12px;padding-bottom:12px;">'
f'{_zone_visuals_7}</div>'
f'<div style="padding:10px 40px;border-top:1px solid #cbd5e1;background:#f8fafc;text-align:right;">'
f'<span style="font-size:13px;color:#64748b;">[slide-footer]</span></div>'
f'</div>'
)
# Step 7-conn — layout 후보 list 시각 (default = candidates[0]).
_candidates_rows = ""
for i, lc in enumerate(layout_candidates_list):
_is_default = (i == 0)
_is_selected = (lc == layout_preset)
_badge = (
'<span style="background:#dcfce7;color:#166534;padding:2px 8px;border-radius:3px;'
'font-size:11px;font-weight:600;margin-left:6px;">selected</span>'
if _is_selected else ''
)
_default_mark = (
'<span style="background:#e0e7ff;color:#3730a3;padding:2px 8px;border-radius:3px;'
'font-size:11px;font-weight:600;margin-left:6px;">default</span>'
if _is_default else
'<span style="background:#f1f5f9;color:#64748b;padding:2px 8px;border-radius:3px;'
'font-size:11px;font-weight:600;margin-left:6px;">alternative</span>'
)
_candidates_rows += (
f'<li style="padding:6px 0;"><code>{lc}</code>{_default_mark}{_badge}</li>'
)
_candidates_block = (
f'<h2>Layout Candidates (unit_count={len(units)})</h2>'
f'<p>source: <code>templates/phase_z2/layouts/layouts.yaml</code> + '
f'<code>select_layout_candidates(unit_count)</code> '
f'(Step 7-B). default = first entry. <strong>현재 single-decision logic 은 '
f'default 만 사용</strong> — Step 9 application_plan 진입 시 후보 평가.</p>'
f'<ul>{_candidates_rows}</ul>'
)
_write_step_html(
run_dir, 7, "selected_layout",
title=f"Selected Layout — {layout_preset}",
body_html=(
f"<h2>Layout Preset: <code>{layout_preset}</code></h2>"
f"<p>slide canvas {_slide_w}×{_slide_h}px / slide-body {SLIDE_BODY_WIDTH}×{_body_h}px (left {SLIDE_BODY_LEFT} / top {SLIDE_BODY_TOP}). "
f"안 zone 박스 = 실제 비율 그대로.</p>"
f"{_slide_visual_7}"
f"{_candidates_block}"
f"<h2>Layout CSS (raw)</h2>"
f"<pre><code>{json.dumps(layout_css, ensure_ascii=False, indent=2)}</code></pre>"
),
step_status="partial",
inputs=["step06_composition_plan.json"],
outputs=["step07_layout.json", "step07_selected_layout.html"],
)
# ─── Step 8: Zone + Region Ratio (계획값 only — render 전) ───
# Step 8-conn axis (사용자 lock 2026-05-08) — region/display 후보 list per zone (passive 기록).
# placeholder signals (Step 3/4 부재 종속) :
# region_count = 1 (text-only assumption)
# content_type = "text_block"
# flow_type / role_pattern / has_visual_element / details_presence /
# large_table / long_text / fits_in_region = None or False
# Step 3/4 (Content Object 추출 + Internal Composition Planning) 활성화 시
# 이 placeholder 가 실제 content_object 신호로 교체됨 (별 axis).
_step8_placeholder_signals = {
"region_count": 1,
"content_type": "text_block",
"flow_type": None,
"role_pattern": None,
"ratio_asymmetric": False,
"has_visual_element": False,
"details_presence": False,
"large_table": False,
"long_text": False,
"fits_in_region": None,
"_note": (
"Step 3/4 부재 종속 placeholder — 모든 zone 을 text-only / region_count=1 "
"로 가정. 실제 content_object 신호 활성화는 별 axis."
),
}
zone_region_plans = []
for dz in debug_zones:
contract = get_contract(dz.get("contract_id")) or {}
sub_zones = contract.get("sub_zones") or []
cardinality = contract.get("cardinality") or {}
visual_hints = contract.get("visual_hints") or {}
# Step 8-conn — region / display strategy 후보 (placeholder signals).
_region_cands = select_region_layout_candidates(
region_count=_step8_placeholder_signals["region_count"],
flow_type=_step8_placeholder_signals["flow_type"],
role_pattern=_step8_placeholder_signals["role_pattern"],
ratio_asymmetric=_step8_placeholder_signals["ratio_asymmetric"],
has_visual_element=_step8_placeholder_signals["has_visual_element"],
details_presence=_step8_placeholder_signals["details_presence"],
large_table=_step8_placeholder_signals["large_table"],
long_text=_step8_placeholder_signals["long_text"],
)
_display_cands = select_display_strategy_candidates(
content_type=_step8_placeholder_signals["content_type"],
long_text=_step8_placeholder_signals["long_text"],
large_table=_step8_placeholder_signals["large_table"],
fits_in_region=_step8_placeholder_signals["fits_in_region"],
)
zone_region_plans.append({
"position": dz["position"],
"zone_height_px_planned": dz.get("height_px"),
"zone_ratio_planned": dz.get("ratio"),
"zone_width_px_planned": dz.get("width_px"),
"zone_col_ratio_planned": dz.get("width_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_widths_px_planned": layout_css.get("widths_px"),
"zone_ratios_planned": layout_css.get("ratios"),
"zone_col_ratios_planned": layout_css.get("width_ratios"),
"per_zone_plan": zone_region_plans,
# Step 8-conn placeholder signals (사람이 한 곳에서 caveat 확인)
"step8_conn_placeholder_signals": _step8_placeholder_signals,
},
step_status="partial",
pipeline_path_connected=True,
inputs=["step07_layout.json", "step10_frame_contract.json"],
outputs=["step08_zone_region_ratios.json", "step08_zone_content_placement.html"],
note=(
"계획값 only. 실측값은 Step 14 (visual_check). "
"zone-level 만 dynamic, region-level (sub_zone 안 sections) 은 균등 분배 (1/1/1). "
"F29 처럼 비균등 콘텐츠 (transform_table + plain) 는 첫 cell 부족 가능 — Step 8 ⚠ partial. "
"Step 8-conn (사용자 lock 2026-05-08): per_zone_plan[i] 에 region_layout_candidates + "
"display_strategy_candidates 추가 (passive 기록). placeholder signals = text-only / "
"region_count=1 (Step 3/4 부재 종속). 실제 content_object 신호 활성화는 별 axis. "
"Step 9 application_plan input."
),
)
# Step 8 HTML — zone + sub_zone + sections 시각 박스 (균등 분배 보임)
# Step 8-conn — region/display 후보 list 도 zone 별 박스 아래 표시.
_zone_visuals_8 = ""
for i, plan in enumerate(zone_region_plans):
_color = _zone_palette[i % len(_zone_palette)]
_border = _zone_borders[i % len(_zone_borders)]
_zone_h = plan['zone_height_px_planned'] or 300
_sub_zones_html = ""
for sz in plan['sub_zones_planned']:
_card = sz['cardinality_strict'] or 1
_accepts = ', '.join(sz['accepts'] or [])
# sub_zone 안에 section 박스 cardinality 만큼 균등 분배
_section_boxes = "".join(
f'<div style="flex:1;border:1px dashed #94a3b8;background:rgba(255,255,255,0.7);'
f'padding:6px;display:flex;align-items:center;justify-content:center;'
f'font-size:12px;color:#475569;">section {k+1} (균등 1/{_card})</div>'
for k in range(_card)
)
_sub_zones_html += (
f'<div style="flex:1;background:rgba(59,130,246,0.08);border:2px solid #3b82f6;'
f'padding:8px;display:flex;flex-direction:column;gap:4px;">'
f'<div style="font-weight:700;font-size:13px;padding-bottom:6px;border-bottom:1px solid #3b82f6;">'
f'<code>{sz["id"]}</code><br>'
f'<span style="font-size:11px;font-weight:400;color:#475569;">'
f'cardinality.strict={_card} | accepts: {_accepts}'
f'</span>'
f'</div>'
f'{_section_boxes}'
f'</div>'
)
# Step 8-conn — region / display strategy 후보 시각.
_region_pills = " ".join(
f'<span style="background:#dbeafe;color:#1e40af;padding:2px 10px;border-radius:12px;'
f'font-size:11px;font-weight:600;margin-right:4px;">'
f'{rc}{"" if k == 0 else ""}</span>'
for k, rc in enumerate(plan.get("region_layout_candidates") or [])
)
_display_pills = " ".join(
f'<span style="background:#dcfce7;color:#166534;padding:2px 10px;border-radius:12px;'
f'font-size:11px;font-weight:600;margin-right:4px;">'
f'{ds}{"" if k == 0 else ""}</span>'
for k, ds in enumerate(plan.get("display_strategy_candidates") or [])
)
_zone_visuals_8 += (
f'<h3>zone--{plan["position"]} ({_zone_h}px planned, '
f'min_height {plan["min_height_px"]}px, '
f'frame_cardinality_strict {plan["frame_cardinality_strict"]})</h3>'
f'<div style="height:{min(_zone_h, 360)}px;background:{_color};border:3px solid {_border};'
f'display:flex;gap:8px;padding:12px;box-sizing:border-box;margin-bottom:8px;">'
f'{_sub_zones_html}'
f'</div>'
f'<p style="margin-top:0;color:#64748b;font-size:13px;"><em>{plan["child_distribution_note"]}</em></p>'
f'<div style="background:#f8fafc;border-left:3px solid #94a3b8;padding:8px 12px;margin-bottom:16px;font-size:13px;">'
f'<div style="margin-bottom:4px;"><strong>region_layout_candidates</strong> (★ default): {_region_pills}</div>'
f'<div><strong>display_strategy_candidates</strong> (★ default): {_display_pills}</div>'
f'</div>'
)
_write_step_html(
run_dir, 8, "zone_content_placement",
title="Zone + Child Zone Placement Plan (pre-render)",
body_html=(
f"<p><strong>중요:</strong> 이 페이지는 <em>계획값</em> only. 실제 측정값 (clientHeight / scrollHeight / overflow) 은 "
f"<code>step14_visual_check.json</code> 에서 봄.</p>"
f"<p>각 zone 안에 sub_zones 가 columns 로 나열, 각 sub_zone 안 sections 가 cardinality.strict 만큼 균등 row 분배. "
f"F29 process_column 의 첫 section 이 transform_table (AS-IS/TO-BE 표) 인데 균등 분배로 capacity 부족 → 10px overflow 직접 원인.</p>"
f'<div style="background:#fef3c7;border-left:3px solid #f59e0b;padding:8px 12px;margin-bottom:16px;font-size:13px;">'
f'<strong>Step 8-conn placeholder caveat (사용자 lock 2026-05-08):</strong> '
f'region_layout_candidates / display_strategy_candidates 는 현재 '
f'<code>region_count=1 / content_type="text_block"</code> placeholder 신호로만 산출. '
f'Step 3/4 (Content Object 추출 + Internal Composition Planning) 활성화 시 '
f'실제 content_object 신호로 교체 (별 axis).'
f'</div>'
f"{_zone_visuals_8}"
),
step_status="partial",
inputs=["step07_layout.json", "step10_frame_contract.json"],
outputs=["step08_zone_region_ratios.json", "step08_zone_content_placement.html"],
)
# ─── Step 9 v0: Passive Application Plan (사용자 lock 2026-05-08) ───
# status.md §2 schema 따라 per-unit application_plan 생성.
# v0 = passive — Step 6 default 그대로 사용. runtime 결과 무변.
# invariant 5 가지 (status.md §4) 만족 :
# 1. len(application_candidates) == len(v4_candidates) per unit
# 2. application_candidates[i].template_id == v4_candidates[i].template_id (zip 일관)
# 3. current_default_candidate == v4_candidates[0].template_id (application_status="ok")
# 또는 null (application_status="no_v4_candidate")
# 4. len(units) == Step 6 의 plan_composition 결과 (무변)
# 5. application_status == "ok" iff len(v4_candidates) > 0 iff candidate_status == "ok"
# IMP-06 blocker-fix (Codex #13 Blocker 3 / #16) — pre-build per-unit
# plan-aware lookup so Step 9 application_plan can carry position +
# assignment_source + override-flag fields. Render-record is the canonical
# plan-derived per-position view (built post-frame_overrides); match by
# object identity since render_records[i]["unit"] holds the same instance
# ref as the entry in `units`.
plan_record_by_unit_id = {}
if "render_records" in locals() and render_records:
for rec in render_records:
u = rec.get("unit")
if u is None:
continue
plan_record_by_unit_id[id(u)] = rec
application_plan_units = []
for i, unit in enumerate(units):
# 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 {}
selection_trace = v4_fallback_traces.get(unit.source_section_ids[0], {})
plan_record = plan_record_by_unit_id.get(id(unit))
# Step 7-A axis 보강 — reject 포함 모든 V4 judgments (frontend UI 가
# 모든 frame 의 png 를 카드로 보여주기 위함).
# unit_id = source_section_ids join. parent_merged 는 첫 section 의
# judgments 사용 (parent V4 entry 가 그 section 에 있으므로).
# IMP-08 B-3 : forward sub-section V4 aliases (decimal heading_number)
# when canonical ordinal id misses; U1 default = empty list (no change).
_first_sid = unit.source_section_ids[0]
v4_all_for_unit = lookup_v4_all_judgments(
v4, _first_sid, alias_keys=section_alias_by_id.get(_first_sid)
)
# IMP-32 u4 — per-unit application_plan dict assembly extracted into
# _build_application_plan_unit(...). Per-index/per-id lookups
# (zone_region_plans[i], v4_fallback_traces.get(...),
# plan_record_by_unit_id.get(id(unit)), section_alias_by_id,
# lookup_v4_all_judgments(...)) stay at the call-site.
application_plan_units.append(
_build_application_plan_unit(
unit,
zone_plan,
selection_trace,
plan_record,
v4_all_for_unit,
layout_preset,
layout_candidates_list,
)
)
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,
"units_with_fallback": [
u["unit_id"] for u in application_plan_units if u.get("fallback_used")
],
},
"fallback_policy": comp_debug.get("v4_fallback_summary"),
# Step 7-A axis : user override trace
"frame_overrides_applied": frame_overrides_applied,
"frame_overrides_skipped": frame_overrides_skipped,
# IMP-06 blocker-fix (Codex #13 Blocker 3 / #16) — surface the
# section-assignment plan + summary at Step 9 top level so
# frontend / downstream readers do not have to dive into
# comp_debug to see override impact.
"section_assignment_plan": comp_debug.get("section_assignment_plan"),
"section_assignment_summary": comp_debug.get("section_assignment_summary"),
"v0_lock_note": (
"Step 9 v0 passive (사용자 lock 2026-05-08). "
"Step 6 default 그대로 사용 — runtime 결과 byte-동일. "
"auto decision / scoring 은 Step 9 v1 (별 axis)."
),
},
step_status="partial",
pipeline_path_connected=True,
inputs=[
"step05_v4_evidence.json",
"step06_composition_plan.json",
"step07_layout.json",
"step08_zone_region_ratios.json",
],
outputs=["step09_application_plan.json", "step09_application_plan.html"],
note=(
"Step 9 v0 passive application_plan trace (사용자 lock 2026-05-08). "
"V4 후보 + layout / region / display 후보 + V4 label → application_mode "
"변환을 side-by-side 로 기록. v0 invariant 5 가지 (status.md §4) 만족. "
"Step 6 의 default 결정 그대로 (current_default_candidate). "
"auto decision 은 Step 9 v1 (별 axis). region/display 후보는 Step 8-conn "
"의 placeholder signal 종속 (Step 3/4 부재). "
"Step 7-A axis (2026-05-08): frame_overrides_applied 가 사용자 LayoutPanel/"
"FramePanel 선택값을 강제 적용한 trace."
),
)
# Step 9 v0 HTML — per-unit table + application_mode pill.
_mode_color = {
"direct_insert": ("#dcfce7", "#166534"), # green
"same_frame_with_adjustment": ("#dbeafe", "#1e40af"), # blue
"layout_or_region_change": ("#fef3c7", "#92400e"), # yellow (manual)
"exclude": ("#fee2e2", "#991b1b"), # red
}
_unit_blocks_9 = ""
for u in application_plan_units:
_status_badge = (
f'<span style="background:#dcfce7;color:#166534;padding:2px 8px;border-radius:3px;'
f'font-size:11px;font-weight:600;margin-left:6px;">application_status: ok</span>'
if u["application_status"] == "ok" else
f'<span style="background:#fee2e2;color:#991b1b;padding:2px 8px;border-radius:3px;'
f'font-size:11px;font-weight:600;margin-left:6px;">application_status: no_v4_candidate</span>'
)
_default_html = (
f'<code>{u["current_default_candidate"]}</code>'
if u["current_default_candidate"] else '<em>null</em>'
)
_fallback_html = (
f' | <strong>selection_path:</strong> <code>{u.get("selection_path")}</code>'
f' | <strong>selected_v4_rank:</strong> {u.get("selected_v4_rank")}'
f' | <strong>fallback_reason:</strong> <code>{u.get("fallback_reason")}</code>'
if u.get("fallback_used") else
f' | <strong>selection_path:</strong> <code>{u.get("selection_path")}</code>'
)
_layout_pills = " ".join(
f'<span style="background:#e0e7ff;color:#3730a3;padding:1px 8px;border-radius:10px;'
f'font-size:11px;margin-right:3px;">{lc}{"" if k == 0 else ""}</span>'
for k, lc in enumerate(u["layout_candidates"])
)
_region_pills = " ".join(
f'<span style="background:#dbeafe;color:#1e40af;padding:1px 8px;border-radius:10px;'
f'font-size:11px;margin-right:3px;">{rc}{"" if k == 0 else ""}</span>'
for k, rc in enumerate(u["region_layout_candidates"])
)
_display_pills = " ".join(
f'<span style="background:#dcfce7;color:#166534;padding:1px 8px;border-radius:10px;'
f'font-size:11px;margin-right:3px;">{ds}{"" if k == 0 else ""}</span>'
for k, ds in enumerate(u["display_strategy_candidates"])
)
_app_rows = ""
for k, ac in enumerate(u["application_candidates"]):
_bg, _fg = _mode_color.get(ac["application_mode"], ("#f1f5f9", "#475569"))
_is_default = (ac["template_id"] == u["current_default_candidate"])
_default_mark = (
' <span style="background:#fef3c7;color:#92400e;padding:1px 6px;border-radius:3px;'
'font-size:10px;font-weight:600;">current_default</span>'
if _is_default else ''
)
_app_rows += (
f'<tr>'
f'<td style="padding:6px 10px;border-bottom:1px solid #e2e8f0;">'
f'<code>{ac["template_id"]}</code>{_default_mark}</td>'
f'<td style="padding:6px 10px;border-bottom:1px solid #e2e8f0;font-size:12px;">'
f'{ac["v4_label"]}</td>'
f'<td style="padding:6px 10px;border-bottom:1px solid #e2e8f0;">'
f'<span style="background:{_bg};color:{_fg};padding:2px 8px;border-radius:3px;'
f'font-size:11px;font-weight:600;">{ac["application_mode"]}</span></td>'
f'<td style="padding:6px 10px;border-bottom:1px solid #e2e8f0;font-size:12px;">'
f'{"" if ac["auto_applicable"] else ""}</td>'
f'<td style="padding:6px 10px;border-bottom:1px solid #e2e8f0;font-size:11px;color:#64748b;">'
f'{ac["delegated_to"] or ""}</td>'
f'</tr>'
)
_unit_blocks_9 += (
f'<div style="background:#fff;border:1px solid #cbd5e1;border-radius:6px;padding:16px;margin-bottom:20px;">'
f'<h3 style="margin-top:0;">unit: <code>{u["unit_id"]}</code> {_status_badge}</h3>'
f'<p style="margin:4px 0;font-size:13px;color:#475569;">'
f'<strong>layout_preset (default):</strong> <code>{u["layout_preset"]}</code> | '
f'<strong>current_default_candidate:</strong> {_default_html}{_fallback_html}</p>'
f'<p style="margin:4px 0;font-size:13px;"><strong>layout_candidates (★ default):</strong> {_layout_pills}</p>'
f'<p style="margin:4px 0;font-size:13px;"><strong>region_layout_candidates (★ default, placeholder):</strong> {_region_pills}</p>'
f'<p style="margin:4px 0;font-size:13px;"><strong>display_strategy_candidates (★ default, placeholder):</strong> {_display_pills}</p>'
f'<table style="width:100%;border-collapse:collapse;margin-top:10px;font-size:13px;">'
f'<thead><tr style="background:#f8fafc;">'
f'<th style="padding:6px 10px;text-align:left;border-bottom:2px solid #cbd5e1;">template_id</th>'
f'<th style="padding:6px 10px;text-align:left;border-bottom:2px solid #cbd5e1;">v4_label</th>'
f'<th style="padding:6px 10px;text-align:left;border-bottom:2px solid #cbd5e1;">application_mode</th>'
f'<th style="padding:6px 10px;text-align:left;border-bottom:2px solid #cbd5e1;">auto_app.</th>'
f'<th style="padding:6px 10px;text-align:left;border-bottom:2px solid #cbd5e1;">delegated_to</th>'
f'</tr></thead>'
f'<tbody>{_app_rows}</tbody>'
f'</table>'
f'</div>'
)
_summary_block_9 = (
f'<div style="background:#fef3c7;border-left:3px solid #f59e0b;padding:8px 12px;margin-bottom:16px;font-size:13px;">'
f'<strong>Step 9 v0 lock (사용자 2026-05-08):</strong> '
f'passive trace only — Step 6 default 그대로 사용 (runtime 결과 byte-동일). '
f'auto decision / scoring 은 Step 9 v1 (별 axis). '
f'region / display 후보는 Step 8-conn placeholder signal (Step 3/4 부재) 종속.'
f'</div>'
f'<p><strong>units:</strong> {len(application_plan_units)} | '
f'<strong>units_with_no_v4_candidate:</strong> '
f'{len(units_with_no_v4)} ({", ".join(units_with_no_v4) if units_with_no_v4 else "none"})</p>'
)
_write_step_html(
run_dir, 9, "application_plan",
title="Step 9 v0 — Passive Application Plan",
body_html=(
f'{_summary_block_9}'
f'{_unit_blocks_9}'
),
step_status="partial",
inputs=[
"step05_v4_evidence.json",
"step06_composition_plan.json",
"step07_layout.json",
"step08_zone_region_ratios.json",
],
outputs=["step09_application_plan.json", "step09_application_plan.html"],
)
# 7. Render single slide
html = render_slide(slide_title, slide_footer, zones_data, layout_preset, layout_css)
# 8. Write final.html
out_path = run_dir / "final.html"
out_path.write_text(html, encoding="utf-8")
print(f" html : {out_path}")
# ─── Step 13: Render ───
_write_step_artifact(
run_dir, 13, "render",
data={
"final_html_path": str(out_path),
"final_html_size_bytes": out_path.stat().st_size,
"render_inputs": {
"slide_title": slide_title,
"slide_footer": slide_footer,
"zones_count": len(zones_data),
"layout_preset": layout_preset,
},
},
step_status="done",
pipeline_path_connected=True,
inputs=["step07_layout.json", "step12_slot_payload.json", "templates/phase_z2/slide_base.html", "templates/phase_z2/families/*.html"],
outputs=["step13_render.json", "step13_draft_render.html", "final.html"],
note="Jinja2 render 결과 = final.html. step13_draft_render.html 은 final.html 동일 복사 (검토용).",
)
# Step 13 HTML — final.html 그대로 복사 (검토용)
(run_dir / "steps" / "step13_draft_render.html").write_text(html, encoding="utf-8")
# 9. Selenium check
print(" visual : running per-zone overflow check ...")
overflow = run_overflow_check(out_path)
# ─── Step 14: Visual Runtime Check (실측값) ───
_write_step_artifact(
run_dir, 14, "visual_check",
data=overflow,
step_status="partial",
pipeline_path_connected=True,
inputs=["step13_render.json", "final.html"],
outputs=["step14_visual_check.json"],
note=(
"Selenium 실측 — clientHeight / scrollHeight / excess_y / frame_slot_metrics. "
"Step 8 의 계획값과 비교 시 어느 cell 이 overflow 했는지 박힘. "
"image_aspect_mismatch 검사 추가 (IMP-15 실행-1, issue #45) — image_events[] + fail_reasons. "
"table 검사 부재 (실행-2 잔류) — Step 14 ⚠ partial (table only)."
),
)
# ─── IMP-47B u13: Persist validated AI repair proposals to cache ───
# Saves each applied PARTIAL_OVERRIDES proposal AFTER Step 14 visual
# check + per IMP-46 dual-gate. ``visual_check_passed`` reads the
# Selenium overflow result; ``auto_cache`` sourced from Settings
# (CLI --auto-cache wires settings.ai_fallback_auto_cache at parse
# time, src/phase_z2_pipeline.py:5631-5633). ``user_approved`` stays
# False — the pipeline has no UX approval gate; the auto_cache
# opt-in is the documented bypass per IMP-46 u5. Gate violations
# surface as ``cache_save_status='gate_blocked:<reason>'`` on the
# record (cache is a hint, never a hard dependency).
from src.config import settings as _ai_cache_settings
_persist_ai_repair_proposals_to_cache(
ai_repair_records,
visual_check_passed=bool(overflow.get("passed")),
user_approved=False,
auto_cache=bool(_ai_cache_settings.ai_fallback_auto_cache),
)
# 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)
router_decision["v4_fallback_summary"] = comp_debug.get("v4_fallback_summary")
router_decision["v4_fallback_selections"] = comp_debug.get("v4_fallback_selections", [])
router_decision["frame_reselect_fallback_status"] = (
"pre_render_rank_2_3_fallback_implemented; "
"post_render visual-fail rerender remains routed through existing action trace"
)
# ─── Step 16: Overflow Router ───
_write_step_artifact(
run_dir, 16, "router_decision",
data=router_decision,
step_status="done",
pipeline_path_connected=True,
inputs=["step15_fit_classification.json"],
outputs=["step16_router_decision.json"],
note="A2 — category → proposed_action 매핑.",
)
# 11.5 zone_ratio_retry action (A3) — A3 locked rules (사용자 잠금) 그대로.
# retry budget = 1, slide-base 고정, donor 룰, (b) revert 정책.
retry_trace = _attempt_zone_ratio_retry(
run_dir=run_dir,
out_path=out_path,
slide_title=slide_title,
slide_footer=slide_footer,
zones_data=zones_data,
debug_zones=debug_zones,
layout_preset=layout_preset,
layout_css=layout_css,
overflow=overflow,
fit_classification=fit_classification,
router_decision=router_decision,
gap_px=GRID_GAP,
)
# retry 가 *성공* 했으면 overflow / fit_classification / router_decision / debug_zones 를
# post-retry 상태로 갱신 (slide_status 가 새 상태 반영하도록).
if retry_trace.get("retry_passed"):
overflow = retry_trace["post_retry_overflow"]
debug_zones = retry_trace["post_retry_debug_zones"]
layout_css = retry_trace["post_retry_layout_css"]
# post-retry classifier / router 재실행 — 새 overflow 가 통과면 router_active=False
fit_classification = classify_visual_runtime_check(overflow, debug_zones)
router_decision = route_fit_classification(fit_classification)
router_decision["v4_fallback_summary"] = comp_debug.get("v4_fallback_summary")
router_decision["v4_fallback_selections"] = comp_debug.get("v4_fallback_selections", [])
router_decision["frame_reselect_fallback_status"] = (
"pre_render_rank_2_3_fallback_implemented; "
"post_render visual-fail rerender remains routed through existing action trace"
)
# 11.6 retry_failure_classifier + next_action_router (A4 — 분류/매핑만, 실행 X)
# retry 실패 시 failure_type 분류 + next_proposed_action 기록 (escalation 후보).
enrich_retry_trace_with_failure_classification(retry_trace)
# 11.7 IMP-12 u9 — Step 17 deterministic salvage cascade.
# Triggered on donor_slack_insufficient / no_donor_candidates: cross_zone_redistribute
# → glue_compression → font_step_compression (terminal → layout_adjust/frame_reselect).
_ft = (retry_trace.get("failure_classification") or {}).get("failure_type")
if _ft in {"donor_slack_insufficient", "no_donor_candidates"}:
_plan = retry_trace.get("plan") or {}
_tpos = _plan.get("target_zone_position")
_tdz = next((dz for dz in debug_zones if dz.get("position") == _tpos), {}) or {}
_excess = float(_plan.get("target_excess_y") or 0.0)
# Synthesize FitAnalysis from debug_zones + per-zone overflow so cross_zone_redistribute
# can compute feasibility against real Phase Z geometry (Phase Q's calculate_fit is
# not invoked in Phase Z — see comp_debug v4_fallback_summary policy at 2839-2843).
# Each position becomes a "role"; all share conceptual zone "slide_body" so
# fit_verifier.redistribute trades shortfalls between them. shortfall_px is signed:
# scrollHeight - clientHeight > 0 = deficit, < 0 = surplus (matches redistribute()).
from src.fit_verifier import FitAnalysis, RoleFit
_zof = {z.get("position"): z for z in (overflow.get("zones") or [])}
_fa_roles, _fa_containers = {}, {}
for _dz in debug_zones:
_pos = _dz.get("position")
if not _pos:
continue
_alloc = float(_dz.get("height_px") or 0.0)
_zm = _zof.get(_pos) or {}
_ch = float(_zm.get("clientHeight") or _alloc)
_sh = float(_zm.get("scrollHeight") or _ch)
_fa_roles[_pos] = RoleFit(role=_pos, allocated_px=_alloc, shortfall_px=_sh - _ch)
_fa_containers[_pos] = {"zone": "slide_body", "height_px": int(_alloc)}
_salvage_trace = _attempt_salvage_chain(
run_dir=run_dir, out_path=out_path,
slide_title=slide_title, slide_footer=slide_footer,
zones_data=zones_data, layout_preset=layout_preset, layout_css=layout_css,
cascade_inputs={
"fit_analysis": FitAnalysis(roles=_fa_roles),
"containers": _fa_containers,
"min_margin_px": None,
"excess_px": _excess, "excess_after_glue_px": _excess,
"block_count": len((_tdz.get("placement_trace") or {}).get("internal_regions") or []) or 1,
"zone_position": _tpos or "",
"current_font_px": float(_tdz.get("font_size_px") or 0.0),
"available_lines": int(_tdz.get("available_lines") or 0),
"chars_per_line": int(_tdz.get("chars_per_line") or 0),
},
initial_failure_type=_ft, gap_px=GRID_GAP,
)
retry_trace.update(_salvage_trace)
if _salvage_trace.get("salvage_passed"):
overflow = (_salvage_trace["salvage_steps"][-1].get("post_salvage_overflow")) or overflow
fit_classification = classify_visual_runtime_check(overflow, debug_zones)
router_decision = route_fit_classification(fit_classification)
router_decision["v4_fallback_summary"] = comp_debug.get("v4_fallback_summary")
router_decision["v4_fallback_selections"] = comp_debug.get("v4_fallback_selections", [])
router_decision["frame_reselect_fallback_status"] = (
"pre_render_rank_2_3_fallback_implemented; "
"post_render visual-fail rerender remains routed through existing action trace"
)
# Refresh failure_classification / next_action_proposal so Step 18 / Step 19
# do not surface the pre-salvage donor_slack_insufficient / no_donor_candidates
# state. classify_retry_failure short-circuits on salvage_passed=True → both
# fields become None (no failure to classify, no escalation pending).
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=(
"done" if retry_trace.get("retry_passed") or retry_trace.get("salvage_passed")
else "failed" if retry_trace.get("retry_attempted") and not 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 + IMP-12 u8/u9 salvage cascade "
"(cross_zone_redistribute → glue_compression → font_step_compression). "
"Terminal actions (layout_adjust / frame_reselect / details_popup_escalation) still MISSING."
),
)
# ─── Step 18: Failure Classification (A4-1) ───
_write_step_artifact(
run_dir, 18, "failure_classification",
data=retry_trace.get("failure_classification") or {},
step_status="done" if retry_trace.get("failure_classification") else "skipped",
pipeline_path_connected=True,
inputs=["step17_retry_trace.json"],
outputs=["step18_failure_classification.json"],
note="A4-1 — retry 실패 시 failure_type 분류.",
)
# ─── Step 19: Next Action Proposal (A4-2) ───
_write_step_artifact(
run_dir, 19, "next_action",
data=retry_trace.get("next_action_proposal") or {},
step_status="partial" if retry_trace.get("next_action_proposal") else "skipped",
pipeline_path_connected=False,
inputs=["step18_failure_classification.json"],
outputs=["step19_next_action.json"],
note="A4-2 — failure_type → next_proposed_action 1-D mapping. 실제 action 자체는 미구현 (impl_status=MISSING).",
)
# 12. Slide status — 자동 파이프라인 결과 보고 (review/UI 개념 X)
slide_status = compute_slide_status(
sections, units, comp_debug, overflow,
adapter_needed_units=adapter_needed_units,
debug_zones=debug_zones,
)
# IMP-47B u8 — Surface Step 12 AI repair outcomes through slide_status.
# Composes u4 gather errors + u5 apply_status + u7 coverage_invariant
# into a single ``ai_repair_status`` axis the frontend (u11) reads to
# render human_review notifications. Auto pipeline first
# ([[feedback_auto_pipeline_first]]) — no review_queue insertion;
# explicit status enum + human_review_required flag.
slide_status["ai_repair_status"] = _summarize_ai_repair_status(
ai_repair_records, ai_repair_coverage_invariant,
)
# ─── Step 20: Slide Status ───
_write_step_artifact(
run_dir, 20, "slide_status",
data=slide_status,
step_status="done",
pipeline_path_connected=True,
inputs=["step14_visual_check.json", "step17_retry_trace.json", "step19_next_action.json"],
outputs=["step20_slide_status.json", "step20_final_status.html"],
note="자동 파이프라인 최종 결과 보고. overall = PASS/RENDERED_WITH_VISUAL_REGRESSION/PARTIAL_COVERAGE 등.",
)
# Step 20 HTML — 최종 판정 시각 보고
_overall = slide_status.get("overall", "?")
_ov_class = "pass" if "PASS" in _overall else "fail" if "FAIL" in _overall or "REGRESSION" in _overall else "partial"
_vfs = slide_status.get("visual_fail_reasons") or []
_vfs_html = (
"<ul>" + "".join(f"<li>{v}</li>" for v in _vfs) + "</ul>"
if _vfs else "<p>없음</p>"
)
_aligned = slide_status.get("aligned_section_ids") or []
_covered = slide_status.get("covered_section_ids") or []
_filtered = slide_status.get("filtered_section_ids") or []
_ai_repair = slide_status.get("ai_repair_status") or {}
_ai_repair_label = (
f'{_ai_repair.get("status", "?")} '
f'(human_review_required={_ai_repair.get("human_review_required", False)})'
)
_write_step_html(
run_dir, 20, "final_status",
title="Final Slide Status",
body_html=(
f'<h2>Overall: <span class="{_ov_class}">{_overall}</span></h2>'
f'<table>'
f'<tr><th>rendered</th><td>{slide_status.get("rendered")}</td></tr>'
f'<tr><th>visual_check_passed</th><td>{slide_status.get("visual_check_passed")}</td></tr>'
f'<tr><th>full_mdx_coverage</th><td>{slide_status.get("full_mdx_coverage")}</td></tr>'
f'<tr><th>aligned_section_ids</th><td>{_aligned}</td></tr>'
f'<tr><th>covered_section_ids</th><td>{_covered}</td></tr>'
f'<tr><th>filtered_section_ids</th><td>{_filtered}</td></tr>'
f'<tr><th>adapter_needed_count</th><td>{slide_status.get("adapter_needed_count", 0)}</td></tr>'
f'<tr><th>content_truncated_count</th><td>{slide_status.get("content_truncated_count", 0)}</td></tr>'
f'<tr><th>ai_repair_status</th><td>{_ai_repair_label}</td></tr>'
f'</table>'
f'<h2>Visual Fail Reasons</h2>{_vfs_html}'
f'<h2>Note</h2><p>{slide_status.get("note", "")}</p>'
),
step_status="done",
inputs=["step14_visual_check.json", "step17_retry_trace.json"],
outputs=["step20_slide_status.json", "step20_final_status.html"],
)
# 13. Debug.json
debug_path = write_debug_json(
run_dir, layout_preset, debug_zones, layout_css, overflow,
composition_debug=comp_debug,
slide_status=slide_status,
fit_classification=fit_classification,
router_decision=router_decision,
retry_trace=retry_trace,
)
print(f" debug : {debug_path}")
# ─── Step 21: Debug Trace ───
_write_step_artifact(
run_dir, 21, "debug_index",
data={
"debug_json_path": str(debug_path),
"debug_json_size_bytes": debug_path.stat().st_size,
"placement_trace_recorded_zones": len(debug_zones),
"frame_slot_metrics_count": len(
(overflow or {}).get("frame_slot_metrics")
or (overflow or {}).get("details", {}).get("frame_slot_metrics", [])
),
},
step_status="partial",
pipeline_path_connected=True,
inputs=[],
outputs=["step21_debug_index.json", "debug.json"],
note="placement_trace per-zone + frame_slot_metrics F29 만 기록. region marker partial 미주입 — Step 21 ⚠ partial.",
)
# ─── Step 22: 사용자 확인 / Export (future, UI 영역) ───
_write_step_artifact(
run_dir, 22, "user_export",
data={"status": "future", "scope": "UI 영역 — 자동 파이프라인 범위 외"},
step_status="future",
pipeline_path_connected=False,
inputs=[],
outputs=["step22_user_export.json"],
note="UI / export 단계. 자동 파이프라인 범위 외 — 사용자 직접 확인 / 다운로드.",
)
# 13. Status report
overall = slide_status["overall"]
print(f" status : {overall}")
if slide_status["filtered_section_ids"]:
print(f" filtered_sections = {slide_status['filtered_section_ids']}")
if slide_status["adapter_needed_count"]:
print(f" adapter_needed_count = {slide_status['adapter_needed_count']}")
if slide_status["content_truncated_count"]:
print(f" content_truncated = "
f"{[(c['position'], c['truncated_count']) for c in slide_status['content_truncated_units']]}")
if not slide_status["visual_check_passed"]:
for r in (overflow.get("fail_reasons") or [])[:3]:
print(f" visual_fail = {r}")
# fit_classifier + router 결과 요약
if not fit_classification["visual_check_passed"]:
cats = fit_classification["categories_seen"]
print(f" fit_categories = {cats}")
if router_decision["router_active"]:
actions = router_decision["proposed_actions_summary"]
status = router_decision["implementation_status_summary"]
missing = router_decision["missing_actions_pending_impl"]
print(f" proposed_actions = {actions}")
print(f" impl_status_summary = {status}")
if missing:
print(f" missing_actions = {missing} (현재 미구현 → abort)")
# retry 결과 요약 (A3) + failure classification / next action proposal (A4)
if retry_trace.get("retry_attempted"):
print(f" retry_action = {retry_trace['retry_action']}")
print(f" retry_passed = {retry_trace['retry_passed']}")
if not retry_trace["retry_passed"]:
print(f" retry_failure = {retry_trace.get('retry_failure_reason')}")
fc = retry_trace.get("failure_classification") or {}
nap = retry_trace.get("next_action_proposal") or {}
if fc.get("failure_type"):
print(f" failure_type = {fc['failure_type']}")
if nap.get("next_proposed_action"):
print(f" next_proposed_action = {nap['next_proposed_action']} "
f"(impl_status={nap.get('next_action_implementation_status')})")
# 13. Exit 정책 — visual fail 은 abort, partial coverage 는 abort 안 하지만 PASS 도 아님
if not slide_status["visual_check_passed"]:
err_path = write_overflow_error(run_dir, overflow)
print(f"\n[Phase Z-2 MVP-1.5b] FAIL @ visual_runtime_check ({overall})", file=sys.stderr)
for reason in overflow.get("fail_reasons", [overflow.get("error", "unknown")]):
print(f" - {reason}", file=sys.stderr)
print(f" error : {err_path}", file=sys.stderr)
sys.exit(1)
if not slide_status["full_mdx_coverage"]:
print(
f"\n[Phase Z-2 MVP-1.5b] PARTIAL — visual check OK 지만 "
f"sections {slide_status['filtered_section_ids']} 가 composition planner 에서 "
f"필터됨 (allowed_statuses 미통과). final.html 은 viable units 만 렌더된 "
f"partial artifact. full MDX slide 아님."
)
return out_path
print(
f"\n[Phase Z-2 MVP-1.5b] {overall} — visual check OK + full MDX coverage. "
f"최종 사용자 브라우저 검증 후 ship 가능."
)
return out_path
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(
prog="python -m src.phase_z2_pipeline",
description="Phase Z-2 MVP-1.5b pipeline (MDX → 1 slide HTML).",
)
parser.add_argument("mdx_path", type=Path, help="MDX 파일 경로")
parser.add_argument(
"run_id", nargs="?", default=None,
help="run_id (출력 디렉토리 이름). 없으면 자동 생성 (basename + timestamp).",
)
parser.add_argument(
"--override-layout", dest="override_layout", default=None,
metavar="PRESET",
help=(
"layout_preset 강제 (8 preset 중 하나 — single, horizontal-2, vertical-2, "
"top-1-bottom-2, top-2-bottom-1, left-1-right-2, left-2-right-1, grid-2x2). "
"없으면 자동 결정 (count-based v0)."
),
)
parser.add_argument(
"--override-frame", dest="override_frames", action="append", default=[],
metavar="UNIT_ID=TEMPLATE_ID",
help=(
"unit_id 의 frame template 강제 변경. UNIT_ID 는 \"+\".join(source_section_ids) "
"(e.g., 03-1 또는 03-1+03-2). multiple 가능: --override-frame 03-1=foo "
"--override-frame 03-2=bar"
),
)
parser.add_argument(
"--override-zone-geometry", dest="override_zone_geometries", action="append", default=[],
metavar="ZONE_ID=X,Y,W,H",
help=(
"zone position (top/bottom/left/right/...) 의 slide-body 내부 비율 (0~1) "
"강제. horizontal-2 / vertical-2 만 지원. multiple 가능: "
"--override-zone-geometry top=0,0,1,0.3 --override-zone-geometry bottom=0,0.3,1,0.7"
),
)
# IMP-06 (#6) — zone-section assignment override (backend/CLI/composition only;
# frontend bridge = #38). ZONE_ID = active layout preset position names
# (single=primary, horizontal-2=top/bottom, grid-2x2=top-left/top-right/...).
parser.add_argument(
"--override-section-assignment",
dest="override_section_assignments",
action="append",
default=[],
metavar="ZONE_ID=section_id[,section_id]",
help=(
"zone position 의 section assignment 강제. ZONE_ID = active layout 의 "
"position name (e.g., top, bottom, left, right, top-left, ...). section_id = "
"MDX section identifier (e.g., 03-1). multiple sections per zone = comma-separated. "
"multiple flags: --override-section-assignment top=03-1 "
"--override-section-assignment bottom=03-2,03-3"
),
)
# IMP-46 u5 — auto-cache opt-in. When set, ``cache.save_proposal``
# bypasses the ``user_approved`` gate only (``visual_check_passed``
# is never bypassable). Source of truth is
# ``settings.ai_fallback_auto_cache`` (src/config.py); this flag
# mutates the setting in-process so downstream callers read the
# same value through Settings rather than parsing args themselves.
parser.add_argument(
"--auto-cache",
dest="auto_cache",
action="store_true",
default=False,
help=(
"Allow cache.save_proposal to bypass the user_approved gate "
"(visual_check_passed remains mandatory). Sets "
"settings.ai_fallback_auto_cache=True for this run."
),
)
args = parser.parse_args()
if args.auto_cache:
from src.config import settings as _settings
_settings.ai_fallback_auto_cache = True
overrides_frames: dict[str, str] = {}
for ov in args.override_frames:
if "=" not in ov:
print(
f"[error] --override-frame must be UNIT_ID=TEMPLATE_ID, got: '{ov}'",
file=sys.stderr,
)
sys.exit(2)
k, v = ov.split("=", 1)
overrides_frames[k.strip()] = v.strip()
overrides_geoms: dict[str, dict] = {}
for ov in args.override_zone_geometries:
if "=" not in ov:
print(f"[error] --override-zone-geometry must be ZONE_ID=X,Y,W,H, got: '{ov}'", file=sys.stderr)
sys.exit(2)
zid, vals = ov.split("=", 1)
parts = vals.split(",")
if len(parts) != 4:
print(f"[error] --override-zone-geometry expects 4 floats X,Y,W,H, got: '{vals}'", file=sys.stderr)
sys.exit(2)
try:
x, y, w, h = (float(p) for p in parts)
except ValueError:
print(f"[error] --override-zone-geometry floats parse fail: '{vals}'", file=sys.stderr)
sys.exit(2)
overrides_geoms[zid.strip()] = {"x": x, "y": y, "w": w, "h": h}
# IMP-06 — parse --override-section-assignment into dict[str, list[str]].
# Hard errors per Codex #2/#3 lock : missing `=` / empty ZONE_ID / empty section
# list / duplicate ZONE_ID / duplicate section across zones (parse-time).
overrides_section_assignments: dict[str, list[str]] = {}
_seen_sections_across_zones: dict[str, str] = {} # section_id -> zone_id (first seen)
for ov in args.override_section_assignments:
if "=" not in ov:
print(
f"[error] --override-section-assignment must be ZONE_ID=section_id[,section_id], "
f"got: '{ov}'",
file=sys.stderr,
)
sys.exit(2)
zid, vals = ov.split("=", 1)
zid = zid.strip()
if not zid:
print(
f"[error] --override-section-assignment ZONE_ID must be non-empty, got: '{ov}'",
file=sys.stderr,
)
sys.exit(2)
section_ids = [s.strip() for s in vals.split(",") if s.strip()]
if not section_ids:
print(
f"[error] --override-section-assignment section list must be non-empty, "
f"got: '{ov}'",
file=sys.stderr,
)
sys.exit(2)
if zid in overrides_section_assignments:
print(
f"[error] --override-section-assignment duplicate ZONE_ID '{zid}' "
f"(first assignment kept). Provide each zone only once.",
file=sys.stderr,
)
sys.exit(2)
for sid in section_ids:
if sid in _seen_sections_across_zones:
print(
f"[error] --override-section-assignment section '{sid}' appears in "
f"multiple zones ('{_seen_sections_across_zones[sid]}' and '{zid}'). "
"A section may be assigned to at most one zone.",
file=sys.stderr,
)
sys.exit(2)
_seen_sections_across_zones[sid] = zid
overrides_section_assignments[zid] = section_ids
run_phase_z2_mvp1(
args.mdx_path,
args.run_id,
override_layout=args.override_layout,
override_frames=overrides_frames or None,
override_zone_geometries=overrides_geoms or None,
override_section_assignments=overrides_section_assignments or None,
)